You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Four modules (719 LOC) plus three test files (~1117 LOC) form a closed island with no entry point from the app. They implement a complete alternative message-thread data layer that has never run in production.
useConversationRealtime.ts ← no importers at all
├── useConversationRealtimeSync.ts (:24)
│ └── decrypt-message.ts (:22)
├── decrypt-message.ts (:23)
│ └── decryption-cache.ts (:21)
└── decryption-cache.ts (:19)
Verified beyond static imports: no import( with a matching specifier, no string-built module paths. The only surviving references in src/ are doc comments pointing into the island.
Provenance: born dead, not orphaned
useConversationRealtime.ts was present in the initial commit (893f552, 2025-12-04) — and page.tsx in that same commit already had the full-refetch loadMessages. Both designs have coexisted since day one; the refetch won by default.
git log --all -S "useConversationRealtime" -- src/app/ src/components/ returns empty across all history (control query with useTypingIndicator over identical paths returns results, proving the query works). There is no commit to roll back to, because it was never wired.
It has already cost real time, twice
2026-03-24, a5d4e3d — an engineer fixed Firefox CI Realtime flake by adding 55 lines of polling fallback and E2E DOM signals into the dead hook. It fixed nothing.
Twelve hours later, 812cead — re-diagnosed and hand-copied 42 lines into page.tsx. The commit title says it outright: "fix: wire polling fallback into messages page (was dead code)".
The port carried the polling fallback but not the per-message incremental decrypt — which is why the live path now refetches and re-decrypts 50 rows to learn about one new message. Two copies of the subscribe/poll/teardown block now sit side by side and have already drifted.
Deleting is not a cleanup nicety. This island has a demonstrated negative return.
Harvest first, in the same PR
upsertMessage's merge semantics (decrypt-message.ts:235-267, 33 lines) → page.tsx:356-368, replacing the content-matching heuristic. Fix two things on the way:
The sequence_number sort is wrong for pending rows. assign_sequence_number is COALESCE(MAX(sequence_number), 0) + 1 (migration:1096), so real sequences start at 1 and the seq-0 client placeholders sort to the top of the entire thread. The module docstring at :228-229 claims the created_at tiebreak handles offline placeholders; it only orders seq-0 rows against each other. Use a separate pending bucket pinned after the highest known sequence. (The live path gets this right today by appending — do not regress it.)
The anti-downgrade branch (:245-254) keeps stale content while adopting edited/edited_at, so an edit can be silently dropped.
The three-way failure triage (:139-162) — "please sign in again" / "sender encryption keys unavailable" / "encrypted with previous keys" are better than the single placeholder at message-service.ts:859.
The knowledge that the per-event public-key round-trip is the real waste (message-service.ts:727). The ECDH derive at :801 is cheap.
Do not harvest decryption-cache.ts. Its cache key ${conversationId}:${otherParticipantId} (decrypt-message.ts:99) omits key identity, so it cannot self-heal on rotation; privateKeyCache holds an extractable private key and buys nothing over getCurrentKeys(); and no invalidation is wired at all. A listener-based fix is structurally blind to peer rotation anyway — static-static ECDH means K(A,B) changes when either side rotates. Memoize per (messageId, keyEpoch) with evict-and-retry-once instead, if this is ever needed.
onKeysChanged/notifyKeyChange (key-service.ts:64-77) becomes provably dead once the island goes — delete with it.
Documentation debt this created (fix or the deletion is half-value)
docs/SECURITY-ARCHITECTURE.md:185-198 documents the shared-secret cache as a live DoS mitigation, quotes "~50ms to ~1ms", and names src/hooks/useConversationRealtime.ts. The mitigation it documents is not in effect in the running app.
key-service.test.ts:34 and :49 assert an onKeysChanged listener "in decrypt-message.ts" — no such listener exists in that file.
tests/e2e/messaging/real-time-delivery.spec.ts:169 credits the dead sync hook with data-messages-subscribed, which is actually set by page.tsx:213-218.
specs/010-group-chats schedules work against it: tasks.md:106 (T038) and :108 (T040) both say "update useConversationRealtime", plus plan.md:80 and research.md:157. Group chats cannot be built by modifying a hook that never runs — retarget these before they're picked up.
Prevent recurrence: add knip to CI
package.json has no knip, ts-prune, unimported, or depcheck. TypeScript's unused-symbol checks do not span modules, so nothing in this repo can mechanically detect a closed dead island — which is how one survived eight months starting from the initial commit's own output. This is the highest-leverage item here; it would also have caught the 131 files deleted in #82 and the remaining leads in #90.
One honest counter-argument
vitest.config.ts:180-185 sets 58% coverage thresholds. Deleting 719 well-covered LOC while the surviving, untested 755-LOC page.tsx stays will move the aggregate down. This is the mechanism, not a measured number — coverage was not run. Measure before opening the PR; if it trips, fix it by adding the page.tsx tests that should exist anyway. A gate that penalises deleting code which never executed is miscalibrated, and that's the framing for review.
Scope
Delete:src/hooks/useConversationRealtime.ts, src/hooks/useConversationRealtimeSync.ts, src/lib/messaging/decrypt-message.ts, src/lib/messaging/decryption-cache.ts, their three test files, and key-service.ts:64-77.
Verify:pnpm build (a missed importer fails compilation), pnpm type-check, pnpm lint, full unit suite, and the messaging E2E specs — real-time-delivery.spec.ts in particular, since its comment references the deleted file.
Resolves the messaging cluster of #90. Related: #69 (checklist retargeted), #82 (same class of finding).
Four modules (719 LOC) plus three test files (~1117 LOC) form a closed island with no entry point from the app. They implement a complete alternative message-thread data layer that has never run in production.
Verified beyond static imports: no
import(with a matching specifier, no string-built module paths. The only surviving references insrc/are doc comments pointing into the island.Provenance: born dead, not orphaned
useConversationRealtime.tswas present in the initial commit (893f552, 2025-12-04) — andpage.tsxin that same commit already had the full-refetchloadMessages. Both designs have coexisted since day one; the refetch won by default.git log --all -S "useConversationRealtime" -- src/app/ src/components/returns empty across all history (control query withuseTypingIndicatorover identical paths returns results, proving the query works). There is no commit to roll back to, because it was never wired.It has already cost real time, twice
2026-03-24,
a5d4e3d— an engineer fixed Firefox CI Realtime flake by adding 55 lines of polling fallback and E2E DOM signals into the dead hook. It fixed nothing.Twelve hours later,
812cead— re-diagnosed and hand-copied 42 lines intopage.tsx. The commit title says it outright: "fix: wire polling fallback into messages page (was dead code)".The port carried the polling fallback but not the per-message incremental decrypt — which is why the live path now refetches and re-decrypts 50 rows to learn about one new message. Two copies of the subscribe/poll/teardown block now sit side by side and have already drifted.
2026-08-02, Message reliability: five undiagnosed E2E-encryption bugs #69 — a code review traced a message-loss symptom to
clearDecryptionCaches()in this island and filed it as the confirmed root cause. It cannot be: the function never executes. Correction posted; ~10 of Message reliability: five undiagnosed E2E-encryption bugs #69's 32 checklist items point here.Deleting is not a cleanup nicety. This island has a demonstrated negative return.
Harvest first, in the same PR
upsertMessage's merge semantics (decrypt-message.ts:235-267, 33 lines) →page.tsx:356-368, replacing the content-matching heuristic. Fix two things on the way:sequence_numbersort is wrong for pending rows.assign_sequence_numberisCOALESCE(MAX(sequence_number), 0) + 1(migration:1096), so real sequences start at 1 and the seq-0 client placeholders sort to the top of the entire thread. The module docstring at:228-229claims thecreated_attiebreak handles offline placeholders; it only orders seq-0 rows against each other. Use a separate pending bucket pinned after the highest known sequence. (The live path gets this right today by appending — do not regress it.):245-254) keeps stalecontentwhile adoptingedited/edited_at, so an edit can be silently dropped.:139-162) — "please sign in again" / "sender encryption keys unavailable" / "encrypted with previous keys" are better than the single placeholder atmessage-service.ts:859.message-service.ts:727). The ECDH derive at:801is cheap.Do not harvest
decryption-cache.ts. Its cache key${conversationId}:${otherParticipantId}(decrypt-message.ts:99) omits key identity, so it cannot self-heal on rotation;privateKeyCacheholds an extractable private key and buys nothing overgetCurrentKeys(); and no invalidation is wired at all. A listener-based fix is structurally blind to peer rotation anyway — static-static ECDH means K(A,B) changes when either side rotates. Memoize per(messageId, keyEpoch)with evict-and-retry-once instead, if this is ever needed.onKeysChanged/notifyKeyChange(key-service.ts:64-77) becomes provably dead once the island goes — delete with it.Documentation debt this created (fix or the deletion is half-value)
docs/SECURITY-ARCHITECTURE.md:185-198documents the shared-secret cache as a live DoS mitigation, quotes "~50ms to ~1ms", and namessrc/hooks/useConversationRealtime.ts. The mitigation it documents is not in effect in the running app.key-service.test.ts:34and:49assert anonKeysChangedlistener "in decrypt-message.ts" — no such listener exists in that file.tests/e2e/messaging/real-time-delivery.spec.ts:169credits the dead sync hook withdata-messages-subscribed, which is actually set bypage.tsx:213-218.specs/010-group-chatsschedules work against it:tasks.md:106(T038) and:108(T040) both say "updateuseConversationRealtime", plusplan.md:80andresearch.md:157. Group chats cannot be built by modifying a hook that never runs — retarget these before they're picked up.Prevent recurrence: add
knipto CIpackage.jsonhas noknip,ts-prune,unimported, ordepcheck. TypeScript's unused-symbol checks do not span modules, so nothing in this repo can mechanically detect a closed dead island — which is how one survived eight months starting from the initial commit's own output. This is the highest-leverage item here; it would also have caught the 131 files deleted in #82 and the remaining leads in #90.One honest counter-argument
vitest.config.ts:180-185sets 58% coverage thresholds. Deleting 719 well-covered LOC while the surviving, untested 755-LOCpage.tsxstays will move the aggregate down. This is the mechanism, not a measured number — coverage was not run. Measure before opening the PR; if it trips, fix it by adding thepage.tsxtests that should exist anyway. A gate that penalises deleting code which never executed is miscalibrated, and that's the framing for review.Scope
Delete:
src/hooks/useConversationRealtime.ts,src/hooks/useConversationRealtimeSync.ts,src/lib/messaging/decrypt-message.ts,src/lib/messaging/decryption-cache.ts, their three test files, andkey-service.ts:64-77.Verify:
pnpm build(a missed importer fails compilation),pnpm type-check,pnpm lint, full unit suite, and the messaging E2E specs —real-time-delivery.spec.tsin particular, since its comment references the deleted file.Resolves the messaging cluster of #90. Related: #69 (checklist retargeted), #82 (same class of finding).