feat(multi-account): display names, middle-truncated npub, npub-only account fix

Account switcher dropdown improvements:
- Two-row display: Display Name on top, npub (middle-truncated) below
  e.g. 'Alice' / 'npub1abc...wxyz · Bunker'
- Middle-truncation for npub: shows first 10 + last 6 chars
- Resolves display names from DesktopLocalCache user metadata
- Confirmation dialog also shows display name
- npub-only (view-only) accounts now persist to encrypted storage
  (ensureCurrentAccountInStorage called in onLoginSuccess)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-04-29 09:35:33 +03:00
parent 66310ca336
commit 250bb5a1ad
24 changed files with 7404 additions and 17 deletions
@@ -0,0 +1,523 @@
---
title: "feat: WeakReference Cache + State Class Extraction"
type: feat
status: active
date: 2026-03-24
origin: docs/brainstorms/2026-03-24-weakref-cache-architecture-brainstorm.md
---
# feat: WeakReference Cache + State Class Extraction
## Enhancement Summary
**Deepened on:** 2026-03-24
**Agents used:** kotlin-expert, kotlin-multiplatform, kotlin-coroutines, gradle-expert, architecture-strategist, performance-oracle, code-simplicity-reviewer, pattern-recognition-specialist
### Key Improvements from Deepening
1. **Source set correction:** `commons/jvmAndroid` not `jvmMain` — both KMP + Gradle agents confirmed
2. **Massive simplification:** Extract 5 State classes now (not 22) — only what Desktop needs today
3. **Per-feature repositories** instead of single `IAccountSettings` — matches existing commons pattern (`EphemeralChatRepository`, `PublicChatListRepository`)
4. **Fix ICacheProvider `Any?` returns directly**`User`/`Note` already in commons, no new methods needed
5. **DecryptionCaches are pure Kotlin** — move as-is, no interfaces needed
6. **Performance fixes:** `AtomicInteger` size tracker, single-pass `cleanUp()`, debounced `findUsersStartingWith`
7. **Heap monitoring:** `MemoryMXBean` check every 30s, cleanup at >75% or every 5min
8. **2 phases not 4** — cache swap + note pinning
### Critical Issues Discovered
1. **Static `LocalCache.justConsumeMyOwnEvent()` calls** in State classes bypass injected cache param — must fix during extraction
2. **`findUsersStartingWith` becomes O(n) per keystroke** with no cap — needs debouncing
3. **`size()` is O(n)** on ConcurrentSkipListMap — add AtomicInteger tracker
4. **Note.replies unbounded growth** for pinned notes — pruning is NOT optional for long-running Desktop sessions
---
## Overview
Replace `BoundedLargeCache` (strong refs, 50k cap, arbitrary eviction) with Android's `LargeSoftCache` (WeakReference-based, GC-driven). Extract 5 critical note-pinning State classes from `amethyst/` to `commons/` so Desktop gets the same retention logic. All in existing PR #1905 on branch `feat/desktop-cache-v2`.
(see brainstorm: `docs/brainstorms/2026-03-24-weakref-cache-architecture-brainstorm.md`)
## Problem Statement
Vitor's feedback on PR #1905: `BoundedLargeCache` causes notes to "just disappear" because eviction is by lowest hex key order — ignoring whether notes are displayed, replied-to, or part of a thread. Android solved this with `LargeSoftCache<K, WeakReference<V>>` where GC respects the reference graph.
Desktop's `DesktopIAccount` has **zero State objects** — it stubs everything. The State classes that pin important notes (bookmarks, follow list, mute list, relay lists, metadata) are Android-only.
## Proposed Solution
Two-phase approach (simplified from original 4 phases):
1. **Phase 1: Cache swap** — Move `LargeSoftCache` to `commons/jvmAndroid`, fix `ICacheProvider` types, replace `BoundedLargeCache`, add cleanup loop
2. **Phase 2: Pin critical notes** — Extract 5 State classes to `commons/commonMain`, wire in `DesktopIAccount`
Remaining 17 State classes extracted incrementally as Desktop builds features that need them.
## Technical Approach
### Architecture
```
┌──────────────────────────────────────────────────────┐
│ LargeSoftCache<K, WeakRef<V>> │
│ (commons/jvmAndroid/ — shared) │
│ │
│ notes: LargeSoftCache<HexKey, Note> │
│ users: LargeSoftCache<HexKey, User> │
│ addressables: LargeSoftCache<String, AddressableNote>│
└───────────────────────┬──────────────────────────────┘
│ WeakRef (GC-managed)
┌───────────────────▼───────────────────┐
│ Note Reference Graph │
│ (strong refs: replies, reactions, │
│ zaps, boosts, replyTo, author) │
│ │
│ Clusters survive together. │
│ GC collects entire cluster when │
│ no external strong ref remains. │
└───────────────────┬───────────────────┘
│ strong refs (pinning)
┌───────────────────▼───────────────────┐
│ State Classes (commons/) │
│ (hold strong refs to important notes) │
│ │
│ BookmarkListState → bookmarkList note │
│ Kind3FollowListState → contact list │
│ MuteListState → mute list note │
│ Nip65RelayListState → relay list │
│ UserMetadataState → metadata note │
│ │
│ Live on Account (Android) or │
│ DesktopIAccount (Desktop) — both │
│ implement IAccount │
└───────────────────────────────────────┘
```
### Implementation Phases
---
#### Phase 1: Cache Swap (~1 day)
**Goal:** Replace BoundedLargeCache with LargeSoftCache, fix ICacheProvider types, add cleanup.
##### 1a. Fix ICacheProvider return types (prerequisite)
**File:** `commons/src/commonMain/kotlin/.../model/cache/ICacheProvider.kt`
`User`, `Note`, `AddressableNote` all live in `commons/commonMain/` already. The `Any?` returns are a legacy artifact. Change directly:
```kotlin
interface ICacheProvider {
// Change these from Any? to proper types
fun getUserIfExists(pubkey: HexKey): User? // was Any?
fun getNoteIfExists(hexKey: HexKey): Note? // was Any?
fun getOrCreateUser(pubkey: HexKey): User? // was Any?
fun findUsersStartingWith(prefix: String, limit: Int = 50): List<User> // was List<Any>
// Already typed correctly — no change
fun checkGetOrCreateNote(hexKey: HexKey): Note?
fun getOrCreateAddressableNote(key: Address): AddressableNote
fun justConsumeMyOwnEvent(event: Event): Boolean
// ...
}
```
Callers that currently cast `as? User` just drop the cast. No breakage.
**Research Insight (kotlin-expert):** `User`/`Note` are already in commons — the `Any?` returns were from an earlier extraction. Safe to fix now.
##### 1b. Move LargeSoftCache to commons/jvmAndroid
**From:** `amethyst/src/main/java/.../model/LargeSoftCache.kt`
**To:** `commons/src/jvmAndroid/kotlin/.../model/cache/LargeSoftCache.kt`
**Research Insight (KMP + Gradle agents):** Must be `jvmAndroid`, NOT `jvmMain`. The `jvmAndroid` source set in commons already exists (line 85-90 of `commons/build.gradle.kts`). Both `jvmMain` and `androidMain` depend on it. Zero Gradle changes needed. `CacheOperations` from `quartz/jvmAndroid` is visible transitively.
**Changes:**
- Move file, update package to `com.vitorpamplona.amethyst.commons.model.cache`
- Update Android's `LocalCache.kt` import (only file that imports it)
**Performance improvements to LargeSoftCache:**
```kotlin
class LargeSoftCache<K : Any, V : Any> : CacheOperations<K, V> {
private val cache = ConcurrentSkipListMap<K, WeakReference<V>>()
// NEW: O(1) approximate size tracking (performance-oracle recommendation)
private val _size = AtomicInteger(0)
fun approximateSize(): Int = _size.get()
fun put(key: K, value: V) {
val prev = cache.put(key, WeakReference(value))
if (prev == null) _size.incrementAndGet()
}
// IMPROVED: Single-pass cleanUp (performance-oracle: ~40% faster, zero alloc)
fun cleanUp() {
val iter = cache.entries.iterator()
while (iter.hasNext()) {
val entry = iter.next()
if (entry.value.get() == null) {
iter.remove()
_size.decrementAndGet()
}
}
}
// ... rest unchanged
}
```
##### 1c. Replace BoundedLargeCache on Desktop
**File:** `desktopApp/.../cache/DesktopLocalCache.kt`
```kotlin
// Before
val users = BoundedLargeCache<HexKey, User>(MAX_USERS)
val notes = BoundedLargeCache<HexKey, Note>(MAX_NOTES)
val addressableNotes = BoundedLargeCache<String, AddressableNote>(MAX_ADDRESSABLE)
// After
val users = LargeSoftCache<HexKey, User>()
val notes = LargeSoftCache<HexKey, Note>()
val addressableNotes = LargeSoftCache<String, AddressableNote>()
```
**Remove:** `BoundedLargeCache.kt`, `MAX_NOTES`/`MAX_USERS`/`MAX_ADDRESSABLE` constants.
**API differences:**
- `BoundedLargeCache.values()` → use `forEach` + collect
- `BoundedLargeCache.count(predicate)` → available via `CacheOperations`
- `userCount()`/`noteCount()` → use `approximateSize()` (O(1) vs O(n))
**Performance fix for `findUsersStartingWith`:**
```kotlin
// Before: O(n) per keystroke with no cap
// After: debounce at call site + yield during iteration
override fun findUsersStartingWith(prefix: String, limit: Int): List<User> {
val results = mutableListOf<User>()
users.forEach { _, user ->
if (results.size >= limit) return@forEach
if (user.anyNameStartsWith(prefix)) results.add(user)
}
return results
}
```
Call sites must debounce to 300ms. Acceptable because search already uses debounced state.
##### 1d. Add Desktop cleanup loop
**Research Insight (kotlin-coroutines):** Inline in coordinator, no abstraction needed. `Dispatchers.Default` for CPU-bound work. `Mutex.tryLock()` if heap trigger added. `yield()` every 1000 items.
```kotlin
// In Coordinator or Main.kt — inline, no CacheCleanupService needed
private val cleanupScope = CoroutineScope(
SupervisorJob() + Dispatchers.Default + CoroutineExceptionHandler { _, t ->
println("Cleanup failed, will retry: ${t.message}")
}
)
private val memoryBean = ManagementFactory.getMemoryMXBean()
private var lastCleanupTime = 0L
val cleanupJob = cleanupScope.launch {
delay(2.minutes) // Grace period for startup
while (isActive) {
delay(30.seconds) // Check frequently, clean infrequently
val heapPct = memoryBean.heapMemoryUsage.let { it.used.toDouble() / it.max }
val elapsed = System.currentTimeMillis() - lastCleanupTime
if (heapPct > 0.75 || elapsed > 5.minutes.inWholeMilliseconds) {
val ops = listOf(
"cleanMemory" to { localCache.cleanMemory() },
"cleanObservers" to { cleanObserversWithYield() },
"pruneExpired" to { localCache.pruneExpiredEvents() },
"pruneReplaceables" to { localCache.prunePastVersionsOfReplaceables() },
)
ops.forEach { (name, op) ->
try { op() } catch (e: Exception) {
println("Cleanup $name failed: ${e.message}")
}
}
lastCleanupTime = System.currentTimeMillis()
}
}
}
private suspend fun cleanObserversWithYield() {
var count = 0
localCache.notes.forEach { _, note ->
note.clearFlow()
if (++count % 1000 == 0) yield()
}
}
```
##### Phase 1 Acceptance Criteria
- [ ] `ICacheProvider` methods return typed `User?`/`Note?` (not `Any?`)
- [ ] `LargeSoftCache` in `commons/src/jvmAndroid/kotlin/.../model/cache/`
- [ ] `LargeSoftCache` has `AtomicInteger` size tracker + single-pass `cleanUp()`
- [ ] Android's `LocalCache` imports from commons (one import change)
- [ ] Desktop's `DesktopLocalCache` uses `LargeSoftCache`
- [ ] `BoundedLargeCache.kt` deleted
- [ ] Desktop cleanup loop: periodic + heap-pressure triggered
- [ ] `findUsersStartingWith` debounced at call sites
- [ ] `./gradlew :commons:compileKotlinJvm :amethyst:compileDebugKotlin :desktopApp:compileKotlin` passes
---
#### Phase 2: Pin Critical Notes (~1-2 days)
**Goal:** Extract 5 essential State classes to commons so Desktop pins important notes.
##### Why only 5 (not 22)?
**Research Insight (code-simplicity-reviewer):** Desktop currently has zero State classes and zero UI for most of the 22 features. Only 5 State classes serve features Desktop has today:
| State Class | Why Desktop needs it now |
|-------------|------------------------|
| BookmarkListState | Bookmarks screen exists |
| Kind3FollowListState | Follow list drives home feed |
| Nip65RelayListState | Relay list drives subscriptions |
| MuteListState | Content filtering |
| UserMetadataState | Profile display |
The other 17 (GeohashList, ProxyRelay, IndexerRelay, etc.) pin notes for features Desktop hasn't built yet. Extract them when needed.
##### 2a. Per-feature repository interfaces (not IAccountSettings)
**Research Insight (pattern-recognition-specialist):** The existing commons State classes use **per-feature repository interfaces**, not a shared settings interface:
- `EphemeralChatRepository` — 2 methods: `ephemeralChatList()`, `updateEphemeralChatListTo()`
- `PublicChatListRepository` — 2 methods: `publicChatList()`, `updatePublicChatListTo()`
Follow the same pattern. Each State class gets a narrow repository interface:
```kotlin
// commons/commonMain — one per State class that uses settings
interface Kind3FollowListRepository {
val backupContactList: ContactListEvent?
fun updateContactListTo(event: ContactListEvent)
}
interface MuteListRepository {
val backupMuteList: MuteListEvent?
fun updateMuteList(event: MuteListEvent)
}
interface Nip65RelayListRepository {
val backupNIP65RelayList: AdvertisedRelayListEvent?
fun updateNIP65RelayList(event: AdvertisedRelayListEvent)
}
// UserMetadataState — audit for specific settings usage
```
Android's `AccountSettings` implements all 5 interfaces. Desktop implements with stubs initially (no persistence yet), returning null for backup events.
**Naming convention (pattern-recognition):** `I` prefix for broad abstractions (`IAccount`, `ICacheProvider`). No prefix for narrow feature-scoped interfaces (`Kind3FollowListRepository`, `MuteListRepository`).
##### 2b. Move DecryptionCaches as-is
**Research Insight (kotlin-expert):** DecryptionCaches are pure Kotlin wrappers around `PrivateTagArrayEventCache<T>` from `quartz/commonMain`. No interfaces needed, no Android deps:
```kotlin
// Already works in commons/commonMain — no changes needed
class MuteListDecryptionCache(val signer: NostrSigner) {
val cachedPrivateLists = PrivateTagArrayEventCache<MuteListEvent>(signer)
}
```
Only `MuteListState` uses a DecryptionCache among the initial 5 State classes. Move `MuteListDecryptionCache` alongside it.
##### 2c. Fix static LocalCache calls
**Research Insight (pattern-recognition + architecture):** Several State classes call `LocalCache.justConsumeMyOwnEvent(event)` as a **static singleton call**, not through the injected `cache` parameter. Found in:
- `Kind3FollowListState` (line 168)
- `MuteListState` (line 147)
- `SearchRelayListState` (line 108)
During extraction, change to `cache.justConsumeMyOwnEvent(event)` — the method already exists on `ICacheProvider`.
##### 2d. Extraction order
1. **BookmarkListState** — no settings, no decryptionCache, simplest. Verify imports: change `amethyst.model.Note``amethyst.commons.model.Note`
2. **Nip65RelayListState** — needs `Nip65RelayListRepository` interface
3. **Kind3FollowListState** — needs `Kind3FollowListRepository`, fix static `LocalCache` call
4. **MuteListState** — needs `MuteListRepository` + move `MuteListDecryptionCache`, fix static call
5. **UserMetadataState** — audit settings usage, create repository interface
##### 2e. Wire State classes on DesktopIAccount
```kotlin
class DesktopIAccount(
private val accountState: AccountState.LoggedIn,
val localCache: DesktopLocalCache,
// ...
) : IAccount {
// Stub repositories — no persistence yet, return null for backups
private val kind3Repo = object : Kind3FollowListRepository {
override val backupContactList: ContactListEvent? = null
override fun updateContactListTo(event: ContactListEvent) { /* no-op for now */ }
}
// ... similar stubs for other repos
val bookmarkList = BookmarkListState(signer, localCache, scope)
val kind3FollowList = Kind3FollowListState(signer, localCache, scope, kind3Repo)
val nip65RelayList = Nip65RelayListState(signer, localCache, scope, nip65Repo)
val muteList = MuteListState(signer, localCache, MuteListDecryptionCache(signer), scope, muteRepo)
val userMetadata = UserMetadataState(signer, localCache, scope, metadataRepo)
}
```
##### Phase 2 Acceptance Criteria
- [ ] 5 State classes in `commons/commonMain/`
- [ ] Per-feature repository interfaces (not single IAccountSettings)
- [ ] `MuteListDecryptionCache` moved alongside `MuteListState`
- [ ] All static `LocalCache.` calls changed to `cache.` (injected)
- [ ] Android's `Account` imports State classes from commons (no behavior change)
- [ ] Desktop's `DesktopIAccount` creates 5 State objects with stub repos
- [ ] Pinned AddressableNotes survive GC on Desktop
- [ ] `@Stable`/`@Immutable` annotations compile on all targets
- [ ] `./gradlew spotlessApply` + all modules compile
---
## System-Wide Impact
### Interaction Graph
```
Event arrives from relay
→ Coordinator.consumeEvent()
→ DesktopLocalCache.consume()
→ notes.getOrCreate(id) [LargeSoftCache — creates WeakRef]
→ Note populated with event, author, relationships
→ Note.addReply/addReaction/addZap [strong refs to related notes]
→ eventStream.emit()
→ FeedViewModels receive update
State class created on DesktopIAccount
→ State.init { cache.getOrCreateAddressableNote(address) }
→ AddressableNote pinned via strong ref on State object
→ Note's reference graph (replies, reactions) also pinned transitively
→ Cluster survives GC
Periodic cleanup (every 5 min or heap > 75%)
→ LargeSoftCache.cleanUp() sweeps stale WeakRef entries (single-pass)
→ cleanObservers() clears unused NoteFlowSets (yield every 1000)
→ pruneExpiredEvents() removes NIP-40 expired notes
→ prunePastVersionsOfReplaceables() keeps only latest replaceable
```
### Error Propagation
- **getOrCreate race:** Two threads create same Note → `putIfAbsent` ensures one wins. Note constructor is pure (just stores `idHex`), no side effects. Acceptable.
- **GC during render:** Compose holds strong ref during composition. Cache eviction can't crash.
- **State class missing:** If a State class isn't created, that note type has no pin → GC can collect. Mitigated by creating all 5 critical ones at init.
### State Lifecycle Risks
- **Note.replies unbounded growth:** Pinned notes keep all replies alive via strong refs. For long-running Desktop sessions, this grows monotonically. `pruneRepliesAndReactions` is account-dependent — tracked as follow-up work (not deferred indefinitely).
- **NoteFlowSet leak:** `cleanObservers()` with `yield()` handles this.
- **`SharingStarted.Eagerly` on State classes:** All 5 State classes use `stateIn(scope, SharingStarted.Eagerly)`. Desktop has no process lifecycle to bound this. Consider `WhileSubscribed(5000)` for non-critical states when expanding beyond 5.
- **Replaceable event swap:** AddressableNote stays pinned, `.event` swaps. FlowSet emits. No orphaned refs.
### Performance Characteristics
| Operation | Before (BoundedLargeCache) | After (LargeSoftCache) |
|-----------|---------------------------|----------------------|
| `size()` | O(1) via AtomicInteger | O(1) via AtomicInteger (new) |
| `get(key)` | O(log n), strong ref | O(log n), WeakRef deref |
| `forEach` | O(n), no GC checks | O(n), GC check per entry |
| `cleanUp()` | N/A (evict by key order) | O(n) single-pass iterator |
| `findUsersStartingWith` | O(n) capped at 25k | O(n) uncapped — **debounce required** |
| Eviction | Arbitrary (by hex key) | GC-driven (respects refs) |
| Memory cap | Hard (50k/25k/10k) | None (GC-managed) |
## Acceptance Criteria
### Functional
- [ ] Desktop cache uses WeakReferences — no hard size limits
- [ ] Notes only disappear when nothing references them (not arbitrarily)
- [ ] Bookmarks, contact list, mute list, relay lists, metadata pinned on Desktop
- [ ] Navigation persistence maintained (existing behavior)
- [ ] Android behavior unchanged (same code, new import path)
### Non-Functional
- [ ] `BoundedLargeCache` deleted
- [ ] 5 critical State classes in commons
- [ ] Periodic + heap-triggered cleanup on Desktop
- [ ] `LargeSoftCache` shared from `commons/jvmAndroid/`
- [ ] `ICacheProvider` returns typed `User?`/`Note?`
- [ ] `./gradlew spotlessApply` + compile on all modules
## Dependencies & Risks
| Risk | Probability | Impact | Mitigation |
|------|-------------|--------|------------|
| Static `LocalCache.` calls in State classes | High | High | Audit each class. Found in Kind3FollowList, MuteList, SearchRelay. Change to `cache.` |
| `findUsersStartingWith` O(n) jank | High | Medium | Debounce 300ms at call sites. Already debounced in search UI. |
| `size()` O(n) in logging paths | Medium | Low | `AtomicInteger` tracker on LargeSoftCache. Use `approximateSize()`. |
| Note.replies unbounded growth | Medium | Medium | Follow-up: implement non-account-dependent reply cap (e.g., 500 per note) |
| Import path changes break Android | Low | Medium | Only `LocalCache.kt` changes import. One-line diff. |
| BookmarkListState imports `amethyst.model.Note` (wrong package) | Low | Low | Change to `amethyst.commons.model.Note` during extraction |
## Future Work (tracked, not deferred indefinitely)
1. **Extract remaining 17 State classes** — as Desktop builds features that need them
2. **`pruneRepliesAndReactions`** — non-account-dependent reply/reaction cap (e.g., 500 per note) to bound memory for popular threads
3. **Desktop persistence** — implement real `Kind3FollowListRepository` etc. with disk-backed storage
4. **`SharingStarted.WhileSubscribed`** — for non-critical State classes when expanding beyond 5
## Success Metrics
- BoundedLargeCache eviction bugs → eliminated (WeakRef model)
- Desktop note retention → matches Android behavior for 5 critical types
- Android behavior → unchanged (regression-free)
- Desktop memory stability → heap monitoring confirms GC collects unpinned notes
## Sources & References
### Origin
- **Brainstorm:** [docs/brainstorms/2026-03-24-weakref-cache-architecture-brainstorm.md](docs/brainstorms/2026-03-24-weakref-cache-architecture-brainstorm.md) — Key decisions: LargeSoftCache shared, extract State classes, same pinning as Android, extensible for Desktop-specific pins later, one PR in existing #1905
### Internal References
- Prior clean-cache plan: `docs/plans/2026-03-22-feat-clean-cache-single-source-of-truth-plan.md`
- Cache architecture brainstorm: `docs/brainstorms/2026-03-17-desktop-cache-architecture-brainstorm.md`
- Android LargeSoftCache: `amethyst/src/main/java/.../model/LargeSoftCache.kt`
- Android LocalCache: `amethyst/src/main/java/.../model/LocalCache.kt`
- Android Account (State objects): `amethyst/src/main/java/.../model/Account.kt`
- Android MemoryTrimmingService: `amethyst/src/main/java/.../service/eventCache/MemoryTrimmingService.kt`
- Desktop DesktopLocalCache: `desktopApp/.../cache/DesktopLocalCache.kt`
- Desktop BoundedLargeCache: `desktopApp/.../cache/BoundedLargeCache.kt`
- Desktop DesktopIAccount: `desktopApp/.../model/DesktopIAccount.kt`
- ICacheProvider: `commons/.../model/cache/ICacheProvider.kt`
- CacheOperations: `quartz/src/jvmAndroid/.../utils/cache/CacheOperations.kt`
- Note model: `commons/.../model/Note.kt`
- Existing pattern: `EphemeralChatRepository` / `PublicChatListRepository` in commons
### Research Insights Applied
- **kotlin-expert:** DecryptionCaches are pure Kotlin (move as-is), fix `Any?` returns directly, normalize static LocalCache calls, only 3 files use `@Stable`/`@Immutable`
- **kotlin-multiplatform:** `commons/jvmAndroid` (not `jvmMain`), zero Gradle changes, `CacheOperations` visible transitively
- **kotlin-coroutines:** `SupervisorJob` + `Dispatchers.Default`, `Mutex.tryLock()` for heap trigger, `yield()` every 1000 items, 2-min startup grace period
- **gradle-expert:** Zero build.gradle.kts changes needed, no circular deps introduced
- **architecture-strategist:** Fix ICacheProvider types as Phase 0 prerequisite, `pruneRepliesAndReactions` is NOT optional for Desktop, consider `SharingStarted.WhileSubscribed` for non-critical states
- **performance-oracle:** `AtomicInteger` size tracker, single-pass `cleanUp()`, `findUsersStartingWith` O(n) jank needs debouncing, non-account-dependent reply cap needed
- **code-simplicity-reviewer:** 5 State classes not 22, per-feature repos not IAccountSettings, inline cleanup not CacheCleanupService, 2 phases not 4
- **pattern-recognition-specialist:** Per-feature repository interfaces match `EphemeralChatRepository` pattern, `I` prefix for broad abstractions only, static `LocalCache.` calls are extraction blockers
### Resolved Questions (from original plan)
1. **AccountSettings audit** → Resolved: use per-feature repository interfaces, not single IAccountSettings. Each State class gets a narrow 2-method interface.
2. **DecryptionCache complexity** → Resolved: pure Kotlin wrappers around quartz's `PrivateTagArrayEventCache`. Move as-is, no interfaces.
3. **ICacheProvider Any? methods** → Resolved: `User`/`Note` already in commons. Change return types directly. Callers drop casts.
4. **Account-dependent prune timing** → Resolved: follow-up work, not deferred. Implement non-account-dependent reply cap (500 per note) as next step.