summaryrefslogtreecommitdiffstats
path: root/docs/playlists.md
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-10 18:44:53 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-10 18:44:53 +0200
commitb88bb8a8bcdc27a092f902f7adbe90373f15732e (patch)
tree3da3f3a89f71fe40a57a581126dcdbc6de69635a /docs/playlists.md
parent676df16e90f894ba034a943d669789efb6721449 (diff)
downloadmeshbay-b88bb8a8bcdc27a092f902f7adbe90373f15732e.tar.gz
docs(mnp): the protocol reference, brought to the cleanup push
MESHBAY_NODE_PROTOCOL.md and playlists.md move into the repository, where a reader can follow them. The protocol reference was written against the tree before the cleanup landed and described three things that no longer exist. Corrected here rather than left to be discovered: * member_upload is gone from the handshake ack and from the message catalogue. Whether a member may write is a property of each root, and a summary field beside the authoritative one is a second source for one question — whichever the reader consults first decides it. * video_root, audio_root and photo_roots are gone from the ack, the signed-op table and the message reference. app_directories is not "the general form" of three narrower ops any more; it is the only one. What survives is their storage key on the node, because that is a key on an operator's disk rather than on the wire, and a node upgraded into this has to find its own configuration. * The sender-key implementation the chat section pointed at has been deleted, along with the ratchet. The argument for deriving a key per device stands on its own now instead of pointing at a module to compare against. playlists.md is added as written — a design for a feature that is not built, and the first one to need per-account state spanning several groups on several nodes. Its decided shape is summarised in MESHBAY_DESIGN.md section 9.10. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YVoHVCcfBqud6ZjG4db3y7
Diffstat (limited to 'docs/playlists.md')
-rw-r--r--docs/playlists.md516
1 files changed, 516 insertions, 0 deletions
diff --git a/docs/playlists.md b/docs/playlists.md
new file mode 100644
index 0000000..aef2366
--- /dev/null
+++ b/docs/playlists.md
@@ -0,0 +1,516 @@
+# MeshBay — Playlists (design)
+
+> Status: **proposal**, not implemented. This is M4 in `docs/musicbay.md` §9,
+> 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 `docs/musicbay.md` first — Music is built, and this adds nothing to
+> its playback path. Read `docs/refactoring-search.md` 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 §5 small; see §10 for what changes if it is ever reversed.
+>
+> Follows the project convention: every claim names the adversary it holds
+> against (§9).
+
+---
+
+## 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.
+
+---
+
+## 1. What this design does not reopen
+
+- **Views over the index, never a catalogue** (`desktop-client-v1.md` §6.10,
+ draft-v6 §2.7). 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, draft-v6 §2.5). §3.1.
+- **No new streaming path.** `musicbay.md` §2.2 stands untouched: a track is
+ fetched through `pipelinedDownload` and handed to `<audio>`. Playlists add
+ no node-side playback code whatsoever.
+- **Node-side derived data lives in the node's own `data_dir`**, never beside
+ a shared file (`mediacenter.md` §2). The blob in §3.3 goes in `bundles.db`.
+- **Identity keys are per node** (`per-node-identity-v1.md`). §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.
+
+---
+
+## 2. Three problems, and only one of them is hard
+
+| | Problem | Where it is solved |
+|---|---|---|
+| A | Somewhere durable to put a few 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 | §4.2 — where the silent failures live |
+| C | Reconciling N copies when nodes are ON and OFF | §5 — the stated fear, and it is well founded *only* for the wrong granularity |
+
+The request reads as a storage problem. Storage is the easy third 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:303`), 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 (§4.2), 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; §6 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 (`per-node-identity-v1.md`).
+
+### 3.3 The node, as an opaque per-account blob — 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" today; the column is the
+ -- reason a second one needs no migration
+ rev INTEGER NOT NULL,
+ blob_enc TEXT NOT NULL,
+ blob_enc_recovery TEXT, -- reserved, §10 O2 — see below
+ stored_at TEXT NOT NULL DEFAULT (datetime('now')),
+ PRIMARY KEY (user_id, kind)
+);
+```
+
+`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 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 the blob must be re-encrypted on
+the next write, exactly as the keypair bundle already is ("*re-encrypted on
+the next backup*").
+
+**AAD** = `"user_blob|playlists|<user_id>"`, mirroring
+`groupbox.associated_data`. It binds the ciphertext to its owner and its
+kind. It does not, and cannot, prevent rollback — §5.4.
+
+---
+
+## 4. The data model
+
+### 4.1 The blob
+
+Plaintext-before-sealing, msgpack (same encoding as everything else on MNP):
+
+```
+{
+ "v": 1,
+ "playlists": {
+ "<playlist_id>": {
+ "name": "Evening",
+ "rev": 7,
+ "device": "<device_pk_ed25519, first 16 hex>",
+ "updated_at": 1757000000, -- display only, never a merge input
+ "deleted": false,
+ "tracks": [ <entry>, ... ]
+ },
+ ...
+ }
+}
+```
+
+`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.
+
+### 4.2 A track reference, and why each field is there
+
+```
+{ "group_id": ..., "file_id": <blake3 hex>, "hash_version": 1|2,
+ "path": "Some Artist/An Album/03 - A Track.flac",
+ "title": ..., "artist": ..., "album": ..., "duration": 214 }
+```
+
+A bare hash is not enough, and each field prevents a specific failure:
+
+- **`group_id`** — the player resolves its connection per track from
+ `entry.groupId` (`music-player.js:288`). Without it there is nothing to
+ dial, and a `file_id` alone has no meaning outside a group.
+- **`title` / `artist` / `album` / `duration`, 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 out, 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 —
+ and *that* is the incoherence the request is worried about. It is a
+ display problem, and it is solved by copying four small strings.
+- **`hash_version`** — the index already has two hashing schemes
+ (`protocol.py:292`: 1 = whole file, 2 = 45 MB sample). A re-hash would
+ orphan every entry in every playlist, silently and all at once.
+- **`path`** — 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 `file_id` is absent but whose `path` matches
+ has its id rewritten in place (and vice versa), once, on the client.
+
+Rehydration is by design cheap: a playlist entry is a subset of the
+`IndexEntry`-plus-`groupId` shape the player already consumes, so
+`onPlayQueue(tracks, startIndex)` takes it unchanged.
+
+**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
+`group_id` has no reachable node but the same `file_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 §9 for
+the one thing it changes.
+
+### 4.3 Size
+
+A thousand tracks at ~200 bytes each is ~200 KB before compression. That is
+small, and it is also an unbounded write primitive pointed at someone else's
+disk, so:
+
+- The node **caps and refuses**, never truncates. 256 KB of `blob_enc` is
+ generous for the shape above. (Noted in passing: `_do_keypair_bundle_store`
+ (`webrtc_server.py:1056`) has no cap at all today. Out of scope here, worth
+ its own line somewhere.)
+- The client **pads the plaintext up to the next 4 KB** before sealing. The
+ ciphertext length otherwise tells the operator roughly how many tracks this
+ account has collected. Cheap, and it is the only metadata this design leaks
+ to a node that the node cannot already see.
+
+---
+
+## 5. 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.
+
+### 5.1 The unit is a playlist, not the collection
+
+The blob is a map keyed by `playlist_id`, and merging is per key. Two
+playlists edited on two devices never collide, which is the overwhelmingly
+common case for one person with two or three devices.
+
+### 5.2 `rev`, never the wall clock
+
+Each playlist 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.
+
+### 5.3 A deletion is a tombstone, never an absence
+
+`deleted: true`, 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.
+
+### 5.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
+§4.2, fully legible.
+
+### 5.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.
+
+---
+
+## 6. 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`, merges, and sends `user_blob_store` back if
+that node's copy is behind. `ConnectionPool` (`search-page.js:36`) 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.
+
+Two additions on top:
+
+- **An explicit "Sync now"** on the Playlists page, 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
+ (§5.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.
+
+---
+
+## 7. Protocol and node-side implementation
+
+### 7.1 MNP — additive, MINOR bump (2.1)
+
+```
+user_blob_store { kind, rev, blob_enc } client → node
+user_blob_fetch { kind } client → node
+user_blob_resp { kind, rev, blob_enc|null } node → client
+```
+
+Modelled on `KEYPAIR_BUNDLE_*` in every respect, including that the node
+stores and returns an opaque string. `kind` is validated against a small
+allowlist (`{"playlists"}`) 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.
+
+### 7.2 Node side
+
+| Piece | Where | What |
+|---|---|---|
+| Storage | `meshbay_node/bundle_store.py` | `user_blobs` table (§3.3), `store_user_blob` / `fetch_user_blob`, same shape as `store_keypair` / `fetch_keypair` |
+| Handlers | `transport/webrtc_server.py` | `_do_user_blob_store` / `_do_user_blob_fetch`, `self._user_id` from the authenticated session (NS6), never from the message |
+| Cap | same | Refuse over 256 KB with a stated reason; refuse an unknown `kind` |
+| Audit | same | `user_blob_store` / `user_blob_fetch` events, same 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.**
+
+### 7.3 What the node can and cannot do with it
+
+It can delete it, lose it with its disk, or serve a stale copy (§5.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.
+
+---
+
+## 8. Client side
+
+### 8.1 A pure module, deliberately separate from the UI
+
+`static/playlists.js` holds the data layer and **no UI**: load/merge/save,
+the tombstone rules, the id/path repair, seal and open. This is not tidiness.
+Source-reading tests are weak evidence and are most of what this repo can do
+for the SPA — but a pure function over two objects can be *executed*, the way
+`tests/harness/mse_harness.mjs` lifts the real player functions and runs
+them. The merge is the one part of this feature that can be properly tested,
+so it must not be entangled with a component.
+
+`tests/harness/playlist_merge.mjs` runs the real `merge()` over scripted
+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. Model the
+environment, never the code under test.
+
+### 8.2 Where it appears
+
+Playlists cross groups, so they do not belong to a group's Music tab:
+
+- **New route `/playlists`** and `static/playlists-page.js`, a sibling of
+ `/search`, plus a sidebar entry. It renders from the **cached** indexes
+ (`getAllCachedIndexes`), so it opens instantly and works with everything
+ offline; it dials only to play, or on "Sync now".
+- **"Add to playlist"** in `music-app.js` (album and track level) and in
+ `search-page.js`'s music results — both already have the entry in hand,
+ with `groupId` attached.
+- **The player bar needs nothing.** 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. This is worth stating loudly, because the
+ cross-group requirement reads like the hard part and is in fact already
+ built (`musicbay.md` §9b: *"The player needed no change"*).
+
+### 8.3 The IndexedDB detail that will otherwise be missed
+
+`hub-client.js` opens `meshbay` at `IDB_VERSION = 1` with a single store,
+`group_indexes`. Adding a `playlists` store 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.
+
+### 8.4 Checklist, per `apps.md`
+
+1. `playlists.js` (data) and `playlists-page.js` (UI).
+2. Route + sidebar entry in `app.js`.
+3. `webapp.py`'s `_ASSETS` tuple — both new files.
+4. `test_hook_ordering.py` (`STATIC_FILES`) and
+ `test_transport_contracts.py` (`SPLIT_FILES`) — both new files.
+5. i18n keys in all ten `static/locales/*.js`; `test_locales.py` holds them
+ to `en.js`'s key set.
+6. `npm run sync-ui` in `meshbay-client`, confirmed reported.
+
+No `apps.js` registry entry and no `ALLOWED_APPS` change: this is not a group
+application. It is a page, like Search.
+
+---
+
+## 9. 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) |
+| Existence of playlists / how many | ✅ | ✅ | visible — one row, `stored_at`, and a padded length (§4.3) | ✅ |
+| Editing timing | ✅ | ✅ | visible for writes reaching *that* node | ✅ |
+| Integrity of the merged state | — | — | can serve stale or nothing; cannot lower the merged `rev` (§5.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 | — |
+
+**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
+(§5.4/§5.5), not durability guaranteed by any node.
+
+**One thing §4.2'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.
+
+---
+
+## 10. Open items — deliberately deferred
+
+| # | Item | Why not now |
+|---|---|---|
+| O1 | Sharing a playlist with other group members | **Explicitly out of scope** (settled before this document). It is not an extension: a shared playlist is group state, sealed under the GEK, with concurrent writers — which makes §5's per-playlist LWW insufficient and an OR-Set over tracks mandatory. If it is ever wanted, reopen §5, 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) | These are queries over the cached index and need no storage at all. Genuinely a separate feature, and cheaper than this one |
+| O4 | Ordering conflicts resolved better than LWW | Reordering is rare and losing a reorder is survivable; losing an added track is not, and per-playlist `rev` already prevents that for one user. Revisit only with O1 |
+| O5 | Export / import a playlist as a file | Trivial once §8.1 exists (it is `JSON.stringify` of a merge unit) and worth doing, but it is not what makes playlists work across devices |
+| O6 | Tombstone collection driven by an explicit per-node acknowledged `rev` | §5.3's rule is adequate at this scale; a real garbage collector matters at thousands of deletions, which is not a real state |
+
+---
+
+## 11. Acceptance before shipping
+
+1. `tests/harness/playlist_merge.mjs` runs the real `merge()` through the
+ five divergences in §8.1, including the resurrect attempt, and **fails
+ with the tombstone rule removed**. Check that, or the test is decoration.
+2. 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.
+3. Read `bundles.db` back and confirm the playlist plaintext is not 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.
+4. Two browsers, one account, one node: edit in each, converge, and confirm
+ both agree. Then repeat with the node stopped between the two edits, and
+ confirm neither edit is lost when it comes back.
+5. Open `/playlists` with **every** node offline and confirm the page renders
+ in full — names, artists, durations, entries greyed — because that is the
+ requirement §4.2 exists for and it cannot be unit-tested meaningfully.
+6. Confirm the size cap refuses rather than truncates, and that the refusal
+ reaches the client as a stated reason rather than a bare `error`.
+7. 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).