Skip to content

Mvp - #66

Draft
mads-jm wants to merge 162 commits into
mainfrom
mvp
Draft

Mvp#66
mads-jm wants to merge 162 commits into
mainfrom
mvp

Conversation

@mads-jm

@mads-jm mads-jm commented Aug 5, 2026 •

Copy link
Copy Markdown
Owner

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 into mvp) 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 to mvp as 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 mvp as 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.

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 the exec('start …') command-injection branch, contained wn-art:// reads to the artwork roots, gated file:write behind one-shot dialog approvals, hardened shell: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), and set-backend-path behind 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 to mvp/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.

  1. App↔relay companion tunnel — relay + app lockstep deploy (42f8ff4, BREAKING CHANGE on record). POST /session now 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, onClientLeft on phone disconnect). An older relay mints no token and the app fails closed by design — no unauthenticated fallback; the error names the version mismatch.
  2. Phone↔server join contract (3fb8b15, BREAKING CHANGE on record). join now carries pin and reconnectToken; join:ack returns a reconnect token instead of isHost; join:denied is 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 send Cache-Control: no-store so 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:

# Finding (§) Closure
1 shell:open-external command injection via exec('start …') (§1.1) exec branch deleted; per-scheme shape allowlist → shell.openExternal; regression test asserts main.ts never references child_process again. Bonus: shell:open-path (the sharper hole — openPath on a file executes it) locked to app-owned/dialog-approved directories, symlinks resolved first
2 wn-art:// arbitrary file read (§1.2) Contained to the artwork roots, images only; traversal, absolute out-of-tree, prefix-sibling, and NUL all 403
3 file:write arbitrary file write (§1.3) One-shot approval recorded by dialog:save-file in main; approve-A-write-B, stale reuse, and traversal-smuggle all defeated. The review's documents/WhatNext containment fallback was rejected as a sovereignty violation — authority derives from the user's own dialog choice
4 Downloader argv injection (--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-path gated behind dialog provenance or a native confirmation; forged-provenance payloads ignored
5 Unsolicited P2P chunk writes = remote disk-fill (§1.5) Chunks checked against our own transfer registry before any filesystem effect; out-of-bounds writes fail the transfer and discard the partial; the dead inbound first-chunk branch deleted (design question preserved as #65)
6 ws below the CVE-2026-45736 fix (§1.6) All three lockfiles (app, relay, and the service workspace the finding missed) resolve 8.21.2; verified by smoke-starting the listeners

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

  • Join-lockout exemption for reconnect-token holders is deliberately unbounded (0163071): join carries pin and reconnectToken in 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.
  • Served files fail closed after a host restart (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).
  • Backend executable paths are validated at set time, not spawn time (READY review residual): editing downloader-config.json requires userData write access — the same trust level as replacing the binary itself, outside the renderer threat model.
  • Minor defense-in-depth residual also on record: wn-art:// resolves paths but not symlinks (not renderer-reachable; becomes relevant only if artwork gains a second untrusted writer).

Quality gates

Gate Status Notes
npm run lint 0 errors / 12 warnings Burned down from 55/16. All 12 warnings are react-hooks/exhaustive-deps — driving them to 0 changes effect re-run timing, so they are a recorded follow-up in epic-quality-gates, not smuggled into a no-op sweep (runtime-no-op of the burndown itself proven by diffing built output)
npm run typecheck 0 errors Was 16, all environmental; the gate was also build-state-dependent (counted differently after a build) — fixed structurally, and it no longer dirties the tree
npm test (vitest) 563 passing / 40 files P2P protocols, replication, LWW, file transfer, companion, downloader backends. Known gap, on the board: p2p-service.ts, most db services, and nearly all renderer components remain untested (#32, #26, #48)
CI Green, observed on mvp All three gates run in CI on every push to mvp/main and on manual dispatch (db90a17); latest mvp push runs completed green
npm run test:e2e Not in CI Playwright needs a built app + display; covered by the manual QA track

Outstanding before merge

Human live QA (gates this merge — cannot be automated)

  • Handshake fix(p2p): handshake responder replies to responses → infinite handshake+replication loop between two peers #58, two-peer session: app + test-peer — connect, handshake exactly once, replicate, go quiet (no repeating handshake/pull lines over a 2-minute idle window); restart each side to exercise durable checkpoints + reconnect; divergent-edit convergence both directions.
  • Companion lockstep deploy against a real relay process + real phone browser: scan-join via QR (PIN in fragment), wrong-PIN + 10-strike lockout, reconnect with token, same-name second guest stays distinct, and the combination — a joined phone's socket bouncing while another guest's lockout is active (it must stay in the session). Verify the old-relay fail-closed error message while at it.
  • Backend-path flow: Browse… (no prompt) and hand-typed path (native confirmation naming the binary); declined confirmation leaves the old setting untouched.
  • One-time re-pick of a pre-existing default export directory (localStorage → dialog-approved migration), then the four Settings folder buttons.
  • Export a playlist (Markdown + HTML) outside every app directory; export + re-import a theme; artwork renders in Library/Playlist/Session (Spotify-imported and P2P-received); "Open in Spotify" with and without the desktop app (web fallback).

In-flight v0.1.0 touch-up workstreams

Branch bookkeeping

Local main is 3 commits ahead of origin/main (unpushed line ending at c2a48a4 "Major MVP Push…", which is the merge base of this PR — mvp contains all three). Resolve before opening: push local main first so the PR diff on GitHub matches the local main..mvp review 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, so Fixes on the QA-pending items (#56–#58) is safe by construction — if QA fails, the merge doesn't happen.

Closed by this merge:

Close manually with a comment (superseded, not fixed):

  • feat(sessions): implement playback mutex (enforce playbackOwnerId) #36 — playback mutex: superseded by the 2026-08-03 device-local-playback ruling (16c6c0a, 5afb17c) — the ownership UI was removed rather than enforced, and the cross-peer mutex is deferred to the post-MVP session-message channel. A Fixes keyword would misrepresent this as implemented.

Deferred, on the board:

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 no Co-Authored-By lines — 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

mads-jm and others added 30 commits February 14, 2026 08:56
- "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
- 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
mads-jm added 12 commits August 6, 2026 10:58
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.
User signed off on all ten proposed issues; filed as #70-#79. The
merge-gating subset (#70 packaged boot, #71 p2p-gate, #72 schema-guard)
is now a ruling rather than a recommendation.
mads-jm added 6 commits August 6, 2026 18:16
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
mads-jm added 3 commits August 7, 2026 11:56
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

1 active deployment
github-pages — 276cc48b Deployed Aug 7, 2026 by mads-jm via deploy #36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment