Repository navigation
Conversation
- "The Walled Garden Cracks" : Spotify integration, and steps to reduce onboarding overhead + coupling to streaming platform - libp2p work / iteration - Client work : original ui moved to dev tab
: AD : SRS
- Playlist Sync v1 - Import from Spotify - Users v0.1
Milestone 1 — Remote Session Pairing: - relay/relay-server.mjs: persistent ed25519 key across restarts; stable peer ID required since it is baked into invite links - relay/package.json: add @libp2p/crypto for key serialization - app/package.json: add @libp2p/dcutr for direct connection upgrade (relay is bootstrap only; DCUtR hole-punches to WebRTC when NAT allows) - relay-config-store.ts: new — user-persisted relay addresses in userData JSON, no hardcoded relay in source (sovereignty thesis) - p2p-config.ts: remove static RELAY.ADDRESSES array; now runtime-loaded - relay-manager.ts: new — retry-with-backoff relay connection manager - p2p-service.ts: wire RelayManager + DCUtR service; add pull-request bridge via pendingPullRequests Map; add getInviteData(); fix getDiscoveredPeers() and getConnectedPeers() stubs; fix disconnectFromPeer - ipc-protocol.ts: new channels and payload types (relay, presence, pull-request bridge, invite URL) - protocol.ts: session param in createConnectUrl; generateShortCode() - main.ts: relay config IPC handlers; invite URL generation; deep-link protocol handler; replication pull-request bridge - preload.ts: expose getInviteUrl, joinSession, relay CRUD, onRelayStatus, onPeerPresence, respondToPullRequest, onPullRequest - P2PSettings.tsx: new — relay management UI with live status dots - ShareSessionPanel.tsx: new — shows whtnxt:// invite URL + short code; join-session input accepting URL or short code Milestone 2 — Replication wired to sessions: - useSessionReplication.ts: new hook — subscribes to RxDB change streams, pushes local changes to peers, applies incoming REPLICATION_CHANGES, responds to REPLICATION_PULL_REQUEST events with RxDB data - SessionView: activate hook on isActiveSession Milestone 3 — Co-host model + playback mutex: - session-interfaces.ts: add coHostIds + playbackOwnerId to SessionState; optional coHostIds in StartSessionConfig - schemas.ts: add coHostIds to PlaylistDocType; bump to version 2 - database.ts: v1→v2 migration for coHostIds - navigation-store.ts: handOffPlayback(toUserId) and takePlayback(userId) - SessionView: playback controls gated on isPlaybackOwner; hand-off UI libp2p v2 Stream API fixes (pre-existing errors resolved): - handshake.ts, replication.ts, ping.ts: source/sink → stream.send + stream.close; StreamHandler signature corrected; peerStore.all() is Promise not AsyncIterator; serviceName → serviceTag for mDNS https://claude.ai/code/session_01D1Vy5htbEpWnWcce1JdVSp
- docs_md/01 concepts/libp2p.md: new sections on Circuit Relay v2 +
DCUtR (relay-as-user-infrastructure, persistent key, RelayManager,
invite URL format) and libp2p v2 Stream API breaking changes
(AsyncIterable, send/close, StreamHandler, peerStore.all)
- docs_md/01 concepts/Circuit-Relay.md: update relay config to dynamic
store pattern; mark persistent-key pitfall as resolved; add
@libp2p/rendezvous pitfall (package does not exist on npm)
- docs_md/04 architecture/adr/adr-260315-p2p-session-pairing.md: new —
five decisions: relay+DCUtR over DHT, user-configured relay, invite
URL over rendezvous, pull-request bridge correlation pattern, playback
mutex
- docs_md/08 specs/p2p-session-pairing-spec.md: new — full M1–M3 spec
covering relay config store, ping protocol, IPC channels, share UI,
useSessionReplication hook, pull-request bridge, co-host model,
schema v2 migration, libp2p v2 API notes, file inventory
- docs_md/index.md + 00 index/{ADR,CONCEPTS,SPECS}.md: updated indexes
https://claude.ai/code/session_01D1Vy5htbEpWnWcce1JdVSp
Upgrades the test peer from a bare connection probe to a full protocol participant capable of validating playlist interactions end-to-end. New files: - test-peer/src/protocols.js: JS ports of handshake.ts and replication.ts. Registers both /whatnext/handshake/1.0.0 and /whatnext/rxdb-replication/1.0.0 handlers; adds pull-response callback path missing from the TS original. - test-peer/src/session-store.js: In-memory document store with LWW conflict resolution, checkpoint tracking, document factories (track, vote), and chalk-formatted display helpers for playlist/tracks/session/peers views. Changes to index.js: - Imports and wires both protocol modules. - Hoists readline interface to module scope so async protocol callbacks can re-prompt the CLI. - Auto-initiates handshake 200ms after peer:connect (mirrors app behaviour, avoids race where both sides dial before the handler is registered). - Cleans up handshake info on peer:disconnect. - Adds 7 new CLI commands: pull, track-add, playlist, tracks, peers-info, vote, session — each with short aliases (pl, ta, tr, pi, v, ss). https://claude.ai/code/session_01MyHJWWEcdTVCQ7nJbYC636
feat(p2p): remote session pairing — relay, replication, co-host model
…rLa0a feat(p2p): add protocol handlers and session validation to test peer
The utility-process entry point was the one P2P module no test could reach: importing it constructed the singleton, which exits(1) without a parentPort. So the hub where handshake completion, replication checkpointing and main<->utility routing meet had zero coverage, while three fixed bugs (#40, #41, #58) live entirely inside it. Two changes open it, and no more: the class is exported, and the module-tail singleton is only constructed when process.parentPort exists. In the shipped path Electron always provides it, so behaviour changes only in the context that previously could not run at all — invoking the bundle standalone is now an inert no-op instead of an error log plus exit(1). The in-class guard is untouched. Wire format unchanged: no IPC message type, protocol or payload shape is touched. The suite drives the service through a stub parentPort with libp2p, the four protocol modules, the checkpoint store and the relay manager faked, pinning: HANDSHAKE_COMPLETE identity relay and once-per-peer bootstrap of exactly the five session collections; the pull timeout echoing the requester's checkpoint rather than advancing it; pull-response persistence under peerId:collection; relay-address and pending-promise routing; and disconnect/stop lifecycle. newestCheckpoint is left unmocked so the real #41 rule is what runs. Refs #32
Turn taking had no tests at either layer: turn-helpers derives "whose turn is it, really" from the track list for every turn-taking view, and turn-service answers is-it-my-turn from the db before the UI offers the add affordance. Both are load-bearing for #43, so they are pinned before anyone touches them. These are behaviour pins of current logic, quirks included and commented as quirks: the trailing-run walk that breaks on a foreign addedBy, turnTracksAdded reporting 0 once the quota is full, isComplete suppressing the quota, and the unknown-stored-user (indexOf === -1) branch resolving two different ways depending on whether that phantom user filled their quota. turn-service's permissive defaults — missing playlist, non-turn-taking playlist and unassigned turn all answer "yes, your turn" — are pinned exactly as written, including getTurnOrder ignoring an explicit turnOrder that both turn-helpers and playlist-service.advanceTurn honour. No turn logic changed; the db module is mocked, no component is rendered. Refs #32
Two peers adding a track at the same moment mis-attribute the turn, and the bug is invisible until someone reads the derivation closely. These two cases assert the WRONG answers on purpose so the fix has a failing target, and carry a map of the race for whoever takes #43: - write path: playlist-service.addTrackToPlaylist increments turnTracksAdded and calls advanceTurn; LWW merges two track lists without merging counters - two independent auto-advance effects can each fire on the merged state, in TurnManagementPanel and SessionView - the derivation is triplicated (computeEffectiveTurn, TurnManagementPanel's inline copy, SessionView via the helper) and the copies do not even resolve the turn order the same way Pinned at the derivation layer only: no db, no components, no fix. The racy write path is out of scope here. Refs #32 Part of #43
The five services under db/services/ back every playlist, track, identity,
comment and reaction mutation in the app, and only track-sink had a test. The
turn-taking side effects inside addTrackToPlaylist — dedup, per-turn quota
rollover, turn handoff, maxTurns and maxDurationMs auto-complete — were the
most intricate uncovered logic in the renderer, and each of those effects is
replicated to peers, so a silent change desynchronises a live session.
Runs against a real RxDB instance on rxdb/plugins/storage-memory rather than a
fake: these contracts are largely about RxDB semantics ($regex selectors,
findByIds maps, doc.update({ $set }), composite-key upserts), so a hand-rolled
fake would pin the fake instead of the code. The shared harness supplies its own
database, leaving the production Dexie singleton untouched.
searchPlaylists is covered on the happy path only. It interpolates raw query
text into $regex and #67 will route it through escapeRegex the way track-service
already does; encoding today's metacharacter behavior as a contract would force
that two-line fix to rewrite the suite. searchTracks does assert literal
metacharacter handling, since escaping is the established contract there.
Three latent traps are pinned as current behavior rather than fixed, being
outside this cycle's authorized source change:
- every mutator except user-service's returns the pre-update RxDocument
snapshot, so the returned handle carries stale field values (no caller reads
them today; the UI re-renders off reactive queries),
- resolveSpotifyUser persists its display-name backfill but returns the
pre-backfill snapshot,
- createPlaylist silently drops CreatePlaylistInput.maxDurationMs.
Clocks are frozen with toFake: ['Date'] only, so RxDB's internal timers keep
running while updatedAt assertions stay deterministic.
Refs #26
CreatePlaylistInput.ownerId and CreateTrackInput.addedBy were optional, and the services compensated with three non-null assertions (playlist-service:38, track-service:28 and :143). That combination let an attribution-less playlist or track be constructed at a call site and only fail later at schema validation — or, in a build without the ajv validator, be written and then replicated to peers with a missing owner. Attribution is not optional in a collaborative session: it is what turn order rotates over and what the UI renders "added by" from. Every call site already passes both fields, so this is a zero-call-site diff. track-service's two assertions sat on lines that only restated what `...input` already spread in, so they are dropped rather than rewritten. The old "omitting ownerId/addedBy fails" case can no longer be written as a runtime test, so each suite grows a `@ts-expect-error` guard instead: an unused directive is itself a typecheck error, so reverting either field to optional fails `npm run typecheck`. Refs #26
relay/ was the last workspace whose tests were smuggled into app's vitest run. service/ stopped doing that in format-standards when it gained a runner of its own; relay keeps the asymmetry alive, which means `cd relay && npm test` does not exist, relay's suites resolve `vitest` and `ws` out of app/node_modules, and the relay CI job gates lint while its two suites are gated by the app job instead. Mirror the service/ arrangement: a pinned vitest devDependency, a config of relay's own (.mjs, matching relay's plain-ESM source), a real test script, and a Test step on the existing relay job. The include is dropped from app/vitest.config.ts in the same commit so nothing runs twice, and the CI step lands here rather than later so no commit in this range leaves the two suites ungated. Coverage is conserved exactly: app goes 667 tests / 46 files -> 651 / 44, and relay's own runner reports the 16 / 2 that left. The companion-web-parity sha256 gate reads across the workspace boundary via import.meta.url, so it resolves app/src/companion-web/ unchanged.
The Quality & Testing block claimed `cd app && npm test` covered "app + relay". It no longer does. Add relay's own line beside the other per-workspace commands and drop the stale claim. Additive and corrective only — the full CLAUDE.md rewrite is a separate workstream.
Neither is imported anywhere under app/src — WebRTC comes from @libp2p/webrtc. Both have been carried as direct dependencies since before the libp2p transport landed. Because nothing imported them, the removal is observably a no-op: all 23 files under app/dist are byte-identical to the pre-removal build, verified after a clean `npm ci`. Note the tree keeps simple-peer: rxdb declares it, so only the direct declaration goes. webrtc-adapter has no other dependent and leaves the tree along with its `sdp` dependency — the whole lockfile delta is those 21 lines. The lockfile's CRLF line endings are preserved deliberately; npm rewrites the whole file to LF, which buries a 21-line change in an 18,000-line diff.
The hook has never been imported. Confirmed orphaned three times now (dead-code-audit-260322, the 2026-08-05 survey, and again immediately before this commit): nothing references `useCompanionBridge`, and nothing references the three interfaces it exports either (CompanionClientInfo, CompanionReactionEvent, CompanionTimeRequestEvent). Dead code is dead code — git history is the preservation mechanism, so an orphaned file gets deleted rather than carried with an annotation. SUPERSEDED ACCEPTANCE CRITERION epic-session-coordination.md carries a de-orphaning criterion — "useCompanionBridge (or its successor) is imported and active during a live session". That criterion is superseded by the user ruling of 2026-08-06. The hook predates the companion PIN / reconnect-token auth rework, so its transport and join assumptions no longer match the server it would talk to. #39's implementer should start from the epic spec, not from this code. Five vault files still reference the hook and are corrected in the docs workstream. RECOVERY REF The hook last existed at: fe94fa6 app/src/renderer/hooks/useCompanionBridge.ts Retrieve it with: git show fe94fa6:app/src/renderer/hooks/useCompanionBridge.ts Evidence the deletion is a no-op: all 23 files under app/dist are byte-identical to the pre-deletion build, because nothing imported the hook for the bundler to reach. The app lint warning count drops 12 -> 10; both were react-hooks/exhaustive-deps warnings in this file. 10 is the new ceiling.
main.ts carried the largest single handler block in the file: seven `spotify:*` handlers, eleven `SPOTIFY_*` playback-control handlers, the runtime-event bridge and the OAuth arm of the protocol router — ~365 lines of one concern in a module whose job is lifecycle, windows and utility-process wiring. Extend the pattern main.ts already uses for downloads, file transfer and purchases: one cohesive module, one `register*Handlers()` call. `ensureSpotifyModules`, `forwardSpotifyEvent` and `handleSpotifyCallbackUrl` travel with it, so spotify/ now owns its whole surface. The registered IPC surface is identical: the same 73 (channel, method) pairs before and after, none added, removed, renamed, or switched between handle and on. Registration timing is preserved — `registerSpotifyHandlers(() => mainWindow)` is called at the same point in main.ts's module evaluation the handlers occupied inline, not from `app.whenReady()`. Handler bodies are unedited. The only non-move changes are the import paths (./spotify/x -> ./x, one directory down) and reaching the window through an accessor instead of the module-level binding. The accessor is a thunk, not a captured value, because the window is recreated on macOS `activate` and every original read was live — handleSpotifyCallbackUrl in particular reads it after awaits, so it calls the accessor at each use site rather than caching it. main.ts: 1,676 -> 1,323 lines. No behaviour change, no wire format change, no IPC contract change.
Second of the handler groups leaving main.ts. Companion server lifecycle, the relay tunnel, QR-code generation, the time-request acknowledgement and the five state-push channels were 138 lines of one concern sitting in the lifecycle module; they now sit beside the server they drive, so companion/ owns its whole surface the way spotify/ now does. Same shape as the spotify extraction: one cohesive module, one `register*Handlers()` call at the same point in main.ts's module evaluation the handlers occupied inline. Nothing moves into `app.whenReady()`. The registered IPC surface is identical: the same 73 (channel, method) pairs before and after. The five push channels are still `ipcMain.on`, not `handle` — that distinction is part of the surface and is preserved. Handler bodies are unedited. The only non-move changes are the import paths (./companion/companion-server -> ./companion-server, one directory down) and reaching the window through the accessor. The accessor is called inside each server-event callback rather than captured at registration, because those callbacks fire long after registration and the original read the live `mainWindow` binding. main.ts: 1,323 -> 1,195 lines. Wire format unchanged — no IPC channel name or payload shape is touched.
Third and last of the handler groups leaving main.ts. The artwork cache is self-contained — it touches no module state at all, only `app`, `fs` and `path` — so it was the clearest seam left in the file. Its two private helpers, sanitizePathSegment and artworkBaseName, travel with it; nothing else called them. Same shape as the two extractions before it: one cohesive module, one `registerArtworkHandlers()` call at the same point in main.ts's module evaluation the handler occupied inline. `registerArtworkHandlers` takes no arguments precisely because there is no shared state to thread. The registered IPC surface is identical: the same 73 (channel, method) pairs before and after. Handler bodies are unedited — a token-level diff of the moved block against its origin is empty but for the closing brace of the new wrapper function. Reads stay where they were: the renderer still fetches cached files over `wn-art://`, which main.ts registers in app.whenReady() and ipc-guards.resolveArtworkPath still confines to the artwork roots. `shell:open-external` also stays in main.ts, so the process-spawn regression lock in ipc-guards.test.ts keeps pointing at the file that holds the handler it guards. main.ts: 1,195 -> 1,082 lines (1,676 at the start of this cycle).
Consolidates every debt item carried out of the wave-1/wave-2 cycles plus a fresh repo-wide sweep at ceb4ebd. The sweep found six genuine bugs, three of them gate-critical: packaged builds load the deleted index.ejs (production has never booted outside dev mode), the p2p-gate workflow watches directories that no longer exist, and schema-guard greps doc comments instead of migrations. Ten issues are proposed plan-first in §5; §6 recommends P1-P3 as merge-gating.
The non-dev branch of createMainWindow loaded index.ejs, a file the ERB->Vite migration (ee6a9d2) deleted, so a production launch had nothing to load at all. Repointing it at index.html is necessary but not sufficient: vite's default base emits root-absolute asset refs, and under file:// a "/assets/index-<hash>.js" resolves against the filesystem root rather than next to dist/index.html. The window came up blank either way, which is why fixing only the filename would have looked like a fix and shipped the same defect. Driving the real build output through Electron shows both halves. With root-absolute refs the document body is empty, #root has no children and document.styleSheets contributes 0 rules, the script having resolved to file:///assets/index-<hash>.js. With base './' the same build mounts React (#root gains a child), paints the full nav chrome and loads all 2074 CSS rules. base: './' is vite's relative-base shortcut and applies to builds only -- vite resolves it back to '/' for the dev server, confirmed by resolving the config both ways, so `npm run dev` and the isDev branch are untouched. No custom scheme is introduced, so the renderer's file:// origin and wn-art:// resolution are exactly as before. Refs #70
resolveHtmlPath has had zero call sites repo-wide since the ERB->Vite
migration; createMainWindow resolves its own load target from __dirname
and never consulted it. It also encoded a second, divergent idea of where
the renderer lives ("../renderer/" under file://), which is a live
hazard now that the previous commit has settled that question.
Deleted on its own so the no-op is auditable: path.ts is edited rather
than removed because getAssetPath in the same file is live (main.ts:92),
and dist/main.js is byte-identical across this change.
Refs #70
menu.ts exports MenuBuilder and nothing imports it. The app has not had an application menu since main.ts started calling Menu.setApplicationMenu(null) alongside frame: false, so the 321 lines here describe a UI that does not exist -- including a macOS submenu built on the `selector:` API Electron deprecated long ago, which would have been the first thing to break for anyone who did wire it up. Behaviour-preserving by construction (zero importers) and verified as such: dist/main.js is byte-identical across this commit and the previous one, measured both before and after the boot fix. Refs #70
Nothing in CI built the app. dist/ is gitignored and the test job runs vitest with no prior build, so main.ts could name a renderer entry the build has not emitted since the ERB->Vite migration and every gate stayed green -- the two preceding commits fixed a defect that only a launch could surface. This adds the one job that produces build output and checks it: vite + tsup via the existing `npm run build`, then a guard that asserts main.ts's production load target exists in dist/ and that the entry's asset refs resolve relative to it. The root-absolute check is the half that matters most, because that failure mode leaves a file that exists and a window that renders nothing. Run against the pre-fix output it names both offending refs and exits 1; against the current build it passes. Deliberately not a packaging job: no electron-builder, no display, no Playwright. The detection logic lives in scripts/ as pure functions so the existing Test job can hold it to fixtures -- a guard nobody tests is how the two workflows repaired next got into this state. Refs #70
The gate watched app/src/main/p2p/** and app/src/main/handshake.ts, a layout the utility process refactor replaced. Neither path has matched a file for some time, so the workflow never fired: every PR touching the protocol passed a governance control that was structurally incapable of firing. It now watches app/src/utility/**, which holds p2p-service and the handshake/replication/file-transfer/ping protocols, plus app/src/shared/lww/** for the cross-peer convergence semantics and app/src/shared/p2p-config.ts. The two entries that were already valid are kept. Repointing alone would leave the same trap armed for the next refactor, so the list is now checked: a path filter matching zero tracked files fails the existing Test job. The check is asserted against the real tree and against the two stale globs it replaces, so it is proven to reject exactly the state it was blind to. No P2P source is modified -- this is the governance control, not the protocol. Wire format unchanged. Refs #71
The guard was two greps, and both were wrong in the same direction --
towards silence. It collected `version:` numbers with `sort -u` across
the whole of schemas.ts, collapsing five independent collections into one
set: with users at 1, tracks at 3, trackInteractions at 0, playlists at 5
and comments at 1, the deduped set is {0,1,3,5}, so bumping users 1 -> 3
adds no new member and the guard says nothing. Whatever survived that was
then matched with grep -q "$VERSION:" against database.ts, which the doc
comment "// v0 -> v1: Added avatarSource" satisfies exactly as well as a
real strategy -- the real ones are ES shorthand methods, `1(oldDoc) {`,
which that pattern never matched in the first place.
Both fixes are one idea: compare per collection, and read the actual
migrationStrategies object instead of the file's prose. Collections are
joined to their schema constant through database.ts, so the comparison is
made at the granularity RxDB actually migrates at, and every version
between the base and the head must have a strategy rather than just the
topmost.
The logic moves out of inline bash into a script so it can be tested,
which is the reason it was broken for so long. Twelve tests run in the
existing Test job and cover the three cases that matter: the current tree
reports nothing, a bump with no strategy is caught, and users 1 -> 3
while tracks already sits at 3 -- the case sort -u provably missed --
reports both the v2 and v3 gaps.
The gate keeps its warn-and-comment posture; making it blocking is a
governance decision, not a defect fix.
Refs #72
The workflow guide mandates a record per merge and PRS.base defines views over them, but 10 PRs/ was empty across seven merged PRs. Records distill each PR body, carry honest review attribution (wave-1 PRs went through Governor-Architect-Inspector cycles with APPROVE verdicts; the March P2P pair was self-merged, noted as such), and correct the merge dates from pr view data.
Four wave-1 epics still said draft five days after merging as PRs #51-#54; the assertion count in epic-app-reliability-quality disagreed with the grep (three, not four); five files referenced the deleted useCompanionBridge hook; half the architecture docs said three-process while the ADRs said four (the difference was whether preload counts — all now use the ADR's four-process convention); and workflow-story-to-pr §3 pointed reviewers at P2P paths that no longer exist, replaced with the surface p2p-gate.yml actually watches since 8f7a0ad.
The 21 timestamped Nov-2025 notes duplicated content that already lives in concept pages and ADRs; per the vault policy they now carry archived status + pointers instead of competing with the durable form. Their milestone-shaped remainder becomes 09 milestones/milestone-v0.0.0. index.md gains the three directory rows it never had and a status section current to the touch-up phase.
The file described a three-process app with an unenforced P2P path list, a roadmap superseded by the 2026-08-03 social-layer ruling, and commands from before the root prettier package and per-workspace runners existed. Every carried claim was re-verified against HEAD: four-process model with the per-domain IPC split, the p2p-gate.yml path list as the protected-surface source of truth, CI's 8 jobs and the 0-error/10-warning lint ceiling, LWW documented as shipped with its no-clamping posture stated honestly, and sandbox:true explicitly disclaimed rather than implied.
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This merge lands the WhatNext MVP baseline on
main: the full Phase 1 "The Walled Garden Cracks" build — coordinator-model Spotify import, P2P sessions over libp2p with RxDB replication, P2P file transfer v0, the audio downloader stack (yt-dlp/spotDL/Spytify), the phone companion client with relay tunnel, themes, and the trackv1→v2→v3 / playlist v5 migrations — plus two full hardening waves on top of it. Wave 1 (five reviewed lane PRs, #51–#55, merged intomvp) retired the June state-of-the-union debt list: Spotify error resilience, replication durability and convergence, downloader backend tests and custom paths, Manual TrackSource, and a working lint gate. Wave 2 (five epics, committed directly tomvpas atomic commits) closed every merge-blocker from the 2026-08-01 pre-merge review — the review's NOT YET verdict is now discharged finding-by-finding, with the trust-boundary half independently re-verified READY on 2026-08-04.The result: all six 260801 merge-blockers closed, main-process trust boundary hardened against both the renderer and remote peers, companion sessions credentialed at both ends, the two-peer handshake storm fixed, and all three quality gates (lint, typecheck, tests) green locally and in CI. ~120 commits, 519 files, +177k/−40k. Two breaking wire-contract changes ship inside (see Deploy notes) and a short human live-QA checklist gates the merge (see Outstanding).
What's in it
Wave 1 — five reviewed lanes (merged to
mvpas PRs)All five were reviewed in
report-260801-mvp-premerge-review§5 with per-lane MERGE verdicts; 340/340 tests on the integrated tree at review time.feat(sessions): un-stub Manual TrackSource via shared write sink— non-Spotify sessions function: manual track add → source-agnostictrack-sinknormalize→write tail → RxDB → session feed. P2P TrackSource arm explicitly scoped out (feat(p2p): un-stub P2P TrackSource #38), not silently stubbed.fix(spotify): error taxonomy, resilient fetch, degraded-mode handling— closed error taxonomy, transport-only retry with bounded backoff,Retry-Afterhonored, single 401-refresh-retry, degraded-mode event bridge. Retires the "naive error handling" debt item (fix(spotify): 403/429 handling, retry/backoff, token-expiry UX #44, closed at the time).feat(downloader): backend-execution tests + per-backend executable paths— backend-execution coverage against captured real tool output (fixture-driven, sentinel-marker filepath capture), per-backend custom executable paths + presence UX; fixed the latentspotify-id/spotify-idsInputType mismatch that made spotDL unselectable.fix(p2p): replication reliability — durable checkpoints, reconnection, convergent LWW— durable per-peer checkpoints, pull responses actually delivered, skew-aware epoch-ms LWW with a convergence-proof content-key tie-break, 15s echo-checkpoint pull timeout, backpressure-safe sends, jittered reconnect backoff + liveness heartbeat. Reviewed as the strongest code in the repo; human P2P sign-off given per the agentic-work policy.chore: rename eslint flat config to .mjs so it loads— un-broke the lint gate (it then revealed the pre-existing debt wave 2 burned down).Wave 2 — five hardening epics (atomic commits on
mvp)Specs in
WhatNext - docs/08 specs/epic-*.md; each records its cycles, rulings, and accepted risks.epic-ipc-trust-boundary(35c39e7,8721b3c,08b9beb,a983360+ docs) — renderer→main boundary. Deleted theexec('start …')command-injection branch, containedwn-art://reads to the artwork roots, gatedfile:writebehind one-shot dialog approvals, hardenedshell:open-path(directories only, app-owned or dialog-approved — a hole the original review missed), downloader argv hygiene (http(s)-only URLs,--separator on yt-dlp, shape check on spotDL), andset-backend-pathbehind a main-process dialog or native confirmation. Verified READY by adversarial re-review (report-260804-ipc-trust-boundary-review). Closes Epic: IPC trust-boundary hardening (renderer→main entry points) #61.epic-file-transfer-guards(1034582,14556b6,477a815,4186479) — peer→main boundary, three cycles. Cycle 1: inbound chunks validated against our own transfer registry before any filesystem effect (unsolicited chunk = remote disk-fill, closed); concurrency slots owned, not counted (cancel no longer stalls the queue). Cycle 2: file serving authorized by a manifest-time allowlist recording sharing intent, not hash knowledge; refusal byte-identical to not-found so the library can't be enumerated. Cycle 3: receive-path lifecycle races (partial-file handle serialization, resume routed through the queue).epic-handshake-stabilization(fix(p2p): handshake responder replies to responses → infinite handshake+replication loop between two peers #58;b14e13f,96daec9,88c4c76,e14db9d,5534cd9) — the handshake responder answered every inbound message including responses, so two peers ping-ponged handshakes forever and every "completion" re-bootstrapped replication into a pull storm. Now single-stream request/response with once-per-(peer, connection) bootstrap, released on disconnect so reconnects re-sync exactly once. Wire format byte-identical. Test-peer harness brought to LWW/framing parity so live QA exercises what ships.epic-session-liveness-fixes(cycles 1–3;c87706b,ff69121,7c7c04e,6bf48b4,42f8ff4,3fb8b15,16c6c0a) — cycle 1: track-source sync-guard deadlock fixed, Spotify poll widened 2s→12s, downloader outcomes made truthful (fix(downloader): spotDL save JSON parse falls back to lossy stub (title=URL, no album/duration) #56 spotDL parse failure surfaces as a failure instead of a junk stub; fix(downloader): yt-dlp download succeeds but library import skipped when post-processing fails #57 pathless completions render "downloaded, not imported" instead of lying). Cycle 2/2b: companion sessions credentialed — relay host slot requires a 256-bit Bearer token; phone joins require a session PIN + per-client reconnect tokens (both breaking — see Deploy notes). Cycle 3: the false cross-peer "you own playback" UI removed — playback is honestly device-local; the cross-peer mutex is deferred post-MVP with the roadmap ruling on record (feat(sessions): implement playback mutex (enforce playbackOwnerId) #36, superseded).epic-quality-gates(cycles 1–2;905e304,424176b,7408f7a,6f96c13…44af9c2,db90a17) — ws bumped past CVE-2026-45736 in all three listening workspaces; typecheck gate repaired 16→0 (and made independent of build state); lint burned down 55→0 errors; CI runs all three gates on every push tomvp/main.Breaking changes & deploy notes
Two wire-contract breaks ship in this merge, both in the companion stack. Deploy the relay and the app together.
42f8ff4,BREAKING CHANGEon record).POST /sessionnow mints a secret 256-bit host token; the relay only accepts a host WebSocket on/host/<code>presenting it as a Bearer header (constant-time compare, rejected impostors cannot disturb a live host). Host↔relay frames are wrapped in a v1 envelope carrying to/from phone ids (per-phone ack routing, correct client counts,onClientLefton phone disconnect). An older relay mints no token and the app fails closed by design — no unauthenticated fallback; the error names the version mismatch.3fb8b15,BREAKING CHANGEon record).joinnow carriespinandreconnectToken;join:ackreturns a reconnect token instead ofisHost;join:deniedis new. PIN validation is host-side on both transports, so the relay stays a dumb pipe — no relay wire change here — but phones must be served the matching companion-web build, including the relay's static copy (the relay serves tunnelled phones its own/s/<code>client, so an un-redeployed relay hands out a client that can never join). Both copies are pinned by a checksum test; both static servers sendCache-Control: no-storeso a phone holding a stale pre-PIN client can reload out of it.Also behavior-visible, not wire-breaking: a default export directory chosen before this merge lives only in renderer
localStorage, so its "Open" button stays inert until re-picked once via the main-process dialog — deliberate; the renderer cannot grant itself the permission.Security posture
The 2026-08-01 pre-merge review returned NOT YET with six merge-blockers. All six are closed, each recorded in the review's punch list with verification notes:
shell:open-externalcommand injection viaexec('start …')(§1.1)execbranch deleted; per-scheme shape allowlist →shell.openExternal; regression test assertsmain.tsnever referenceschild_processagain. Bonus:shell:open-path(the sharper hole —openPathon a file executes it) locked to app-owned/dialog-approved directories, symlinks resolved firstwn-art://arbitrary file read (§1.2)file:writearbitrary file write (§1.3)dialog:save-filein main; approve-A-write-B, stale reuse, and traversal-smuggle all defeated. The review'sdocuments/WhatNextcontainment fallback was rejected as a sovereignty violation — authority derives from the user's own dialog choice--exec=<cmd>as a "URL") (§1.4)http(s)/base62-id validation at the IPC boundary before any backend is constructed;--separator on yt-dlp;http(s)shape check on spotDL (real 4.5.2 rejects--— verified against the live binary, documented).set-backend-pathgated behind dialog provenance or a native confirmation; forged-provenance payloads ignoredwsbelow the CVE-2026-45736 fix (§1.6)The renderer→main half was re-attacked (not just re-read) in the 2026-08-04 verification review: verdict READY, every bypass class in the brief has a negative-path test importing the shipped guards.
Accepted risks, on record (each with its rationale and tripwire in the vault):
0163071):joincarriespinandreconnectTokenin the same plaintext frame, so any channel that leaks a token leaks the PIN with it — an attacker holding one never needs to guess the other. Tripwire: if a token holder is ever allowed to join without the PIN, or TLS decouples the two, the argument collapses and the exemption needs bounding.477a815): the manifest-time allowlist does not persist; until a manifest re-fetch flow exists, a restarted host serves nothing from previously shared playlists. Chosen over the alternative (persisting authorization the user may no longer intend).downloader-config.jsonrequiresuserDatawrite access — the same trust level as replacing the binary itself, outside the renderer threat model.wn-art://resolves paths but not symlinks (not renderer-reachable; becomes relevant only if artwork gains a second untrusted writer).Quality gates
npm run lintreact-hooks/exhaustive-deps— driving them to 0 changes effect re-run timing, so they are a recorded follow-up inepic-quality-gates, not smuggled into a no-op sweep (runtime-no-op of the burndown itself proven by diffing built output)npm run typechecknpm test(vitest)p2p-service.ts, most db services, and nearly all renderer components remain untested (#32, #26, #48)mvpmvp/mainand on manual dispatch (db90a17); latestmvppush runs completed greennpm run test:e2eOutstanding before merge
Human live QA (gates this merge — cannot be automated)
In-flight v0.1.0 touch-up workstreams
cycle/format-standards— running)Branch bookkeeping
Local
mainis 3 commits ahead oforigin/main(unpushed line ending atc2a48a4"Major MVP Push…", which is the merge base of this PR —mvpcontains all three). Resolve before opening: push localmainfirst so the PR diff on GitHub matches the localmain..mvpreview basis; otherwise the PR will show those 3 commits and a larger stat than reviewed.Issues
Wave-1 PRs targeted
mvp, so their closing keywords never fired — this PR is where the backlog actually closes. Closing keywords below are used only where the work is genuinely complete in this diff; merge is gated on the live-QA checklist above, soFixeson the QA-pending items (#56–#58) is safe by construction — if QA fails, the merge doesn't happen.Closed by this merge:
epic-file-transfer-guards; the remaining stream-semantics design question is its own issue, Design review: file-transfer stream-opening semantics — should a peer ever open a stream and lead with a chunk? #65)7c7c04e; live-QA-gated)6bf48b4; live-QA-gated)b14e13f+ harness parity; live-QA-gated — the two-peer session check above is exactly this issue's acceptance evidence)report-260804)Close manually with a comment (superseded, not fixed):
16c6c0a,5afb17c) — the ownership UI was removed rather than enforced, and the cross-peer mutex is deferred to the post-MVP session-message channel. AFixeskeyword would misrepresent this as implemented.Deferred, on the board:
isLocalidentity adoption), fix(ui): ParticipantRoster implies a co-host role that does not exist #60 (ParticipantRoster co-host implication), Investigate renderer-side fetch('file://…') in theme import #62 (rendererfetch('file://…')in theme import), Download batch never completes if any track errors, stranding successful downloads #63 (batch stranding on track error), spotDL 4.5.2 output format drift: localFilePath capture never fires, downloads not imported #64 (spotDL 4.5.2 output drift →localFilePathcapture), Design review: file-transfer stream-opening semantics — should a peer ever open a stream and lead with a chunk? #65 (file-transfer stream-opening semantics design review)Attribution
Claude Code (Anthropic) contributed substantially across both waves under the repo's agentic-work policy: wave-1 lane implementation and reviews, the wave-2 Governor→Architect→Inspector cycles (briefs, implementation, adversarial reviews), the pre-merge and trust-boundary review reports, and the vault documentation that records the rulings above. Per
CLAUDE.md, commits carry noCo-Authored-Bylines — every commit is human-owned and human-reviewed; AI assistance is acknowledged here instead. P2P-protocol work (handshake.ts, replication, file-transfer protocol) was done under explicit human approval and sign-off as the policy requires.🤖 Generated with Claude Code