Audited all 143 plan files across the 10 plans/ folders. Each plan now carries a Status header (shipped | in-progress | queued | abandoned) backed by codebase evidence, and every folder has a README.md index grouping plans by status. Shipped plans were moved into a per-folder plans/archive/ (via git mv, history preserved) so each plans/ folder surfaces only live work: shipped (archived): 122 in-progress: 8 queued: 7 abandoned: 4 docs/plans/ is the frozen legacy folder; its plans were stamped and indexed in place (48 of 52 archived) but it remains closed to new plans. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016hpUivtmq4pgzqRbY6MYrA
20 KiB
Amethyst → Commons Migration Plan
Status: queued — the keystone Phase A is untouched: no
Account.kt/LocalCache.ktengine in commons, the amethyst monoliths remain (3757 + 4092 LOC), andcommons/model/accountstill holds only the pre-existingAccountInfo/AccountStorage/SignerTypescaffolding the plan's §0 marks as already-present. Audited 2026-06-30.
Date: 2026-05-30 · Owning module: commons · Status: plan (no code moved yet)
Goal: move the shared parts of the amethyst Android app down into commons
so Desktop, the headless cli, and future iOS reuse them, leaving amethyst
as a thin Android-native shell (Activity, navigation, notifications, platform
services). This is the inverse direction of the commons package refactor that
preceded it (see commons/ARCHITECTURE.md) — that cleaned up where shared
code lives; this plan decides what still needs to move there.
Scope surveyed
amethyst/src/main ≈ 1817 Kotlin files. Coupling signals:
| Signal | Files | Meaning |
|---|---|---|
androidx.compose.* |
1075 | UI |
R.string / amethyst.R |
559 | localized strings — see §"Strings" |
android.content.Context |
142 | platform handle to abstract |
Activity / Service / android.app |
40 | clearly stays native |
com.fasterxml.jackson |
6 | purity-gate only matters for commonMain/iOS |
Three structured passes (model/, service/, ui/) produced the matrices below.
0. What is ALREADY in commons (do NOT re-extract)
The agents that surveyed amethyst did not all know commons' current state.
These already exist and the amethyst copies are dedup / re-point targets,
not new extractions:
- Account scaffolding:
model/IAccount,model/account/{AccountInfo, AccountStorage,SignerType},state/{FollowState,UserMetadataState, LoadingState,EventCollectionState},model/cache/*. But the concreteAccount.kt(3565 LOC) andLocalCache.kt(3917 LOC) are NOT extracted — only their interfaces/scaffolding are. They are the keystone (§Phase A). - Relay layer:
relayClient/*(assemblers, eoseManagers, composeSubscription managers, preload, subscriptions, nip17Dm) andrelays/*. - Formatters / util:
util/PubKeyFormatter,util/NumberFormatters,util/PlatformNumberFormatter(expect/actual). amethyst'sui/note/PubKeyFormatter.ktis a duplicate. - Services:
service/lnurl/{LightningAddressResolver,OkHttpLnurlEndpointResolver},service/nwc,service/upload. amethyst'sservice/lnurl/LightningAddressResolver.ktis a duplicate. - Strings: commons localizes through Compose Multiplatform Resources
(
composeResources/values/strings.xml,stringResource(Res.string.x)), already 41 strings. ← This is the migration path forR.string, NOT a customStringProvider. - Image loading: Coil 3 is already KMP —
coil.composeincommonMain,coil.okhttpinjvmAndroid. Shared composables can callAsyncImagedirectly; image loading is largely a non-blocker. - Misc:
tor/,keystorage/(interface + actuals),richtext/,preview/(OpenGraph/MetaTags parsers),feeds/,ui/feeds(feed DAL).
1. Cross-cutting blockers (the real Phase 0)
Ranked by how much extraction they unblock. Note the corrections vs. the raw survey (Coil and Strings are less of a blocker than assumed; account state is more).
| Blocker | Reality | Resolution |
|---|---|---|
Account state (Account.kt, LocalCache.kt, AccountViewModel) |
The dominant blocker. ~53 UI files read it; nearly every ViewModel depends on it. commons has only the interfaces. | Extract Account/LocalCache business logic to commons/model/account + model/cache, behind the existing IAccount/cache interfaces. Keep Android-only bits (LocationState wiring, @Stable) in a thin adapter. Keystone — §Phase A. |
R.string (559) |
Solved pattern already in use. | Migrate needed strings into commons/.../composeResources/values/strings.xml; replace R.string.x → Res.string.x. No new abstraction. Crowdin config already exists at repo root. |
Context (142) |
Mostly file pick / clipboard / intents / cacheDir. | Small expect/actual platform-service interfaces (file picker, clipboard, URI open). Most are confined to ui/actions/uploads and a few service packages. |
INav (nav callbacks) |
Already an interface; low friction. | Shared composables take onNavigate: (route) -> Unit lambdas, not INav directly. |
| Image loading (Coil) | Already KMP in commons. | Use commons Coil directly; only the Android ImageLoaderSetup/fetchers stay native (or move to jvmAndroid). |
| OkHttp / Jackson | Allowed in jvmAndroid; banned only in commonMain/iOS. |
HTTP/JSON code → jvmAndroid. Only promote to commonMain (for iOS) by swapping to Ktor + kotlinx.serialization, and only when iOS needs it. |
2. model/ — migration matrix
Most of amethyst/model is CLI-safe Nostr state and extracts cleanly. Verdicts:
Extract (pure) · Abstract (needs storage/HTTP/keystore seam) ·
Keystone · Native.
| Area | Verdict | Destination | Note |
|---|---|---|---|
Account.kt, LocalCache.kt, accountsCache/ |
Keystone | model/account, model/cache |
3565+3917 LOC; behind IAccount/cache ifaces; LocationState → adapter |
AccountSettings, AccountSyncedSettings* |
Extract | model/account |
pure data + event (de)serialization |
UiSettings, UiSettingsFlow |
Abstract | model/ |
drop R.string (use string keys / Res) |
nip01UserMetadata, nip02FollowLists, nip17Dms, nip30CustomEmojis, nip47WalletConnect, nip51Lists, nip65RelayList, nip72Communities, nip78AppSpecific, nip86RelayManagement, nipA3PaymentTargets, nipB7Blossom, nipBCOnchainZaps, trustedAssertions, zap |
Extract | model/nipNN<slug> (mirror quartz) |
pure event-state wrappers; bulk move |
topNavFeeds/, algoFeeds/, serverList/ |
Extract | feeds/, model/relayLists |
feed query orchestration is platform-agnostic |
edits/ |
Extract | model/nip51Lists or model/nip37Drafts |
encrypted list/draft state |
preferences/ (DataStore, KeyStoreEncryption, *SharedPreferences) |
Abstract | keystorage/, model/preferences |
schemas → commons; persistence actuals stay Android |
marmot/ (Android*Store) |
Abstract | keystorage/marmot + commons logic |
KeyStore-backed stores → expect/actual |
nip03Timestamp, nip11RelayInfo, privacyOptions (OkHttp), localRelays |
Abstract | jvmAndroid (HTTP) + commonMain (logic) |
OkHttp → jvmAndroid; ContentResolver stays native |
torState/ |
Extract | tor/, model/torState |
relay-eval logic is pure |
EncryptedStorage, LocalPreferences |
Abstract | keystorage/ |
core logic + iface; SharedPrefs impl stays Android |
Amethyst.kt (Application), AppModules.kt (DI) |
Native | — | Android lifecycle / Context assembly |
Highest value, lowest friction: the ~16 nipNN state packages + topNavFeeds
(bulk pure moves), then AccountSettings/AccountSyncedSettings.
3. service/ — migration matrix
| Package (files) | Verdict | Destination | Note |
|---|---|---|---|
relayClient (81) |
Native | — (dedup) | Android orchestration wiring over commons relayClient; do NOT duplicate. Lift any LocalCache-free EOSE managers into commons. |
playback (60) |
Native | — | ExoPlayer/Media3, PIP — Android-only |
notifications (15) |
Native | — | foreground service, NotificationManager |
okhttp (17) |
Extract | jvmAndroid |
OkHttp clients/interceptors/WS bridge; already JVM-shaped |
uploads (25) |
Abstract | jvmAndroid + commonMain iface |
compression/codecs stay Android; orchestration → common |
images (10) |
Abstract | jvmAndroid (Coil setup) + commonMain (hashes already in commons) |
|
cashu (10) |
Extract | commonMain (e.g. model/nip60Cashu or service/cashu) |
parser is pure; split off Compose GenericLoadable |
call (10) |
Abstract | commonMain (state) + jvmAndroid (AudioManager/WebRTC) |
WebRTC hard to abstract |
location (6) |
Abstract | commonMain (geohash) + jvmAndroid (Geocoder) |
|
connectivity (3) |
Abstract | commonMain (model) + actual | trivial seam |
broadcast (2), ai (2), previews (2) |
Extract | commonMain (service/, preview) |
previews already wraps commons parsers — consolidate |
lnurl (2) |
Extract / dedup | jvmAndroid service/lnurl |
LightningAddressResolver duplicates commons — reconcile |
namecoin (1) |
Abstract | jvmAndroid |
ElectrumX over OkHttp |
relays (1, EOSE) |
Extract / dedup | relayClient/relays |
consolidate EOSE state |
eventCache (1) |
Abstract | commonMain | eviction logic pure; trim trigger via iface |
scheduledposts (4), calendar (4) |
Native (model extractable) | — | WorkManager-bound; model → commonMain if needed |
crashreports (4), logging (3) |
Native (DTO extractable) | — | Choreographer/Build native |
tts (2), cast (2), nests (2) |
Native | — | TTS / Cast / foreground service |
root utils (ByteFormatter, CountFormatter, etc.) |
Extract | util/ |
some may already exist — check first |
Highest value, lowest friction: okhttp→jvmAndroid, cashu, broadcast,
ai, previews consolidation, lnurl dedup, EOSE consolidation.
4. ui/ — migration matrix
commons rule (from ARCHITECTURE.md): cross-cutting composables → ui/<area>;
feature-specific → <feature>/ui. Deciding test: "could an unrelated feature
reuse this as-is?"
| Area (files) | Shareable | Destination | Blockers |
|---|---|---|---|
ui/dal (5) |
already re-exports | — | none — delete shims, point at ui/feeds |
ui/theme (5) |
~90%, dup | merge into ui/theme |
system-chrome (status/nav bar) stays Android |
ui/feeds (12) |
~95% | ui/feeds |
a few R.string, AccountVM |
ui/layouts (9) |
~85% | ui/layouts |
2× R.string, 1× INav |
ui/components (59) |
~75% | split ui/components (atoms) + <feature>/ui (galleries) |
~30 atoms clean; Coil/AccountVM/R.string on the rest |
ui/note (184) |
~50–70% | formatters→ui/text; avatars→ui/elements/profile/ui; renderers→<nipNN>/ui |
AccountVM (29), R.string (24), Note model coupling |
ui/actions (41) |
~65% | transforms→commonMain; VMs→viewmodels; media→stay Android |
Context (7), AccountVM (7), R.string (8) |
ui/screen (971) |
mostly native | — | app screens; harvest reusable atoms opportunistically |
ui/navigation, ui/call, ui/cast, ui/broadcast, ui/tor |
native | — | platform wiring |
Lowest-friction UI wins (after string migration): TimeAgo/date formatters →
ui/text; ClickableUrl/Email/Phone; GenericLoadable → ui/state; input
transforms; DisappearingBar* → ui/layouts; feed-state composables; ui/dal
shim deletion; ui/theme consolidation. ~42% of note/components/actions
composables (112/266) carry no R.string at all.
5. Reconciliation / dedup backlog (do early — prevents drift)
service/relayClient(Android) vscommons/relayClient— keep Android as wiring only; lift cache-independent EOSE managers down.service/lnurl/LightningAddressResolvervscommons/service/lnurl/…— one canonical copy.ui/note/PubKeyFormattervscommons/util/PubKeyFormatter— delete the dup.ui/themevscommons/ui/theme— single color/type source.ui/dalvscommons/ui/feeds— delete re-export shims.service/relays/EOSE*scattered — one EOSE-state home.
6. Phased roadmap
- Phase A — Account keystone (largest, gates the rest). Extract
Account+LocalCachebusiness logic intocommons/model/account+model/cachebehind the existing interfaces; amethyst keeps a thin Android adapter (LocationState, Compose@Stable). Unblocks ViewModels + interactive UI. - Phase B — pure model & service extracts (parallel, low risk). The ~16
nipNNmodel packages,topNavFeeds,cashu,broadcast,ai,previews, EOSE/lnurl/PubKeyFormatter dedups,okhttp→jvmAndroid. - Phase C — storage/network seams.
keystorageexpect/actual for prefs + KeyStore + marmot stores; HTTP packages (nip11/nip03/namecoin/privacyOptions) →jvmAndroid. - Phase D — string migration + shared UI atoms. Migrate the needed strings
into
composeResources, then extract the low-friction UI list in §4. - Phase E — feature UI. Note type renderers →
<nipNN>/ui; read-only renderers (Badge, Emoji, Poll display) before interactive ones (reactions, composers). Much ofui/note/creatorsand reaction/zap rows may stay native unless Desktop/iOS need them. - Never moves: Application/DI, notifications, playback, WorkManager jobs, TTS, Cast, foreground services, navigation shells, Activity-bound code.
7. Suggested first PR (smallest valuable slice)
Phase B subset — zero account dependency, immediately reusable by Desktop:
the dedups (§5.2–5.5) + a bulk move of 4–6 pure nipNN model packages
(nip17Dms, nip30CustomEmojis, nip47WalletConnect, nip65RelayList,
trustedAssertions, zap). Mechanical, compile-verifiable, no new abstractions.
Phase A is its own large PR and should be planned separately.
8. Architecture end-state (why the tree below is shaped this way)
The ports already exist in commons (IAccount, ICacheProvider,
ICacheEventStream) but every app reimplements them — amethyst
(object LocalCache 3917 LOC + class Account 3565 LOC), desktopApp
(class DesktopLocalCache 739 LOC + DesktopIAccount 278 LOC), cli
(stateless). Crucially these implementations are platform-agnostic already
(Account.kt has one android import — @Stable — plus a single LocationState
coupling; LocalCache.kt has two; DesktopLocalCache has zero). So this is
accidental forking, not essential divergence. The end-state is one
canonical implementation per port, living in commons, with apps keeping only
thin platform adapters.
Two consequences shape the package tree:
- Decompose the monoliths; don't relocate them.
Account(28StateFlows + ~170suspend funs) splits into state (per-user reactive state), actions (the verbs), and a thin façade implementingIAccount.LocalCachebecomes the cache engine as aclass(so every platform gets multi-account; Desktop already needed it). - Litmus test = CLI-constructibility.
signer → cache → account state → actions → relay client → pure servicesmust build in a headlessmain()with no Compose/Android. Anything that can't is mislayered or missing a port.
Layer is the primary axis; NIP is the secondary axis (nipNN<slug> matching
quartz). The raw per-NIP list state lives in model/nipNN<slug>; the
account's composed/derived view of those lists lives in model/account/*. The
generic state/ package (load-a-thing StateFlow machines) is not where
account-specific state goes.
9. Target package hierarchy (the destination tree)
commonMain/.../commons/ (✚ = new package, ⤺ = merge into existing, ✔ = exists):
model/
├─ cache/
│ ├─ ICacheProvider.kt ICacheEventStream.kt ✔ read ports
│ ├─ ILocalCache.kt ✚ write port (from amethyst)
│ ├─ LocalCache.kt ← unified cache ENGINE as a CLASS (amethyst object + DesktopLocalCache collapse here)
│ └─ UserMetadataCache.kt ✔
├─ account/
│ ├─ Account.kt ← façade implementing IAccount (from amethyst Account)
│ ├─ AccountInfo.kt AccountStorage.kt SignerType.kt ✔
│ ├─ settings/ AccountSettings, AccountSyncedSettings(+Internal) ✚
│ ├─ follows/ derived per-feed live-follow lists + all-follows merge ✚
│ ├─ relays/ account relay sets + outbox relay cache ✚
│ └─ hidden/ LiveHiddenUsers — mute/spam aggregated view ✚
├─ nip02FollowList/ ⤺ raw Kind-3 follow-list state (amethyst nip02FollowLists)
├─ nip65RelayList/ ⤺ relay-list state
├─ nip51Lists/ ⤺ mute / bookmark / people lists
├─ nip30CustomEmojis/ ⤺ emoji-pack state
├─ nip01Core/ ⤺ kind-0 user-metadata relay-hint state (amethyst nip01UserMetadata)
├─ nip17Dm/ nip47WalletConnect/ nip60Cashu/ nip72ModCommunities/
│ nip78AppData/ nip85TrustedAssertions/ nip86RelayManagement/
│ nipA3PaymentTargets/ nipB7Blossom/ nipBCOnchainZaps/ ✚ per-NIP state holders (bulk)
└─ settings/ ✚ UiSettings / UiSettingsFlow (global, NOT account-scoped)
state/ ✔ generic StateFlow machines (FollowState, …) — unchanged
actions/ ✔ the account "verbs" — extend, don't duplicate
│ existing: DmActions FollowActions SearchActions ZapActions ZapSplitResolver
│ add: PostActions ReactionActions BookmarkActions MuteActions
│ RelayActions ProfileActions GroupActions DeletionActions …
feeds/
├─ custom/ ✔
├─ topNav/ ✚ (amethyst model/topNavFeeds)
└─ algo/ ✚ (amethyst model/algoFeeds)
relayClient/ ✔ + cache-free EOSE managers lifted from amethyst service/relays
keystorage/
├─ SecureKeyStorage.kt ✔
├─ preferences/ ✚ LocalPreferences logic + DataStore/SharedPrefs schema (+actuals)
├─ account/ ✚ account persistence (pairs with model/account/AccountStorage)
└─ marmot/ ✚ MLS key-package / message / group-state store ports (+actuals)
service/
├─ upload/ nwc/ lnurl/ ✔ (lnurl: dedup amethyst LightningAddressResolver)
├─ broadcast/ ai/ connectivity/ eventCache/ ✚ commonMain (pure)
└─ okhttp/ nip11RelayInfo/ nip03Timestamp/ namecoin/ privacyOptions/ ✚ jvmAndroid (OkHttp/Jackson)
tor/ ✔ + torState relay-evaluation logic
UI destinations follow commons/ARCHITECTURE.md (cross-cutting → ui/<area>,
feature-specific → <feature>/ui) and are detailed in §4 — out of scope for the
"common objects" core tier above.
10. Package-naming decisions (the rules behind §9)
model/nipNN<slug>(raw list state) vsmodel/account/*(composed view).model/nip02FollowListholds the raw Kind-3 list;model/account/followsholds the user's derived per-feed follow flows that compose it. Same for relays (nip65RelayListvsaccount/relays) and mutes (nip51Listsvsaccount/hidden).- NIP slugs match quartz exactly, normalizing amethyst's drift:
nip02FollowLists→nip02FollowList,nip01UserMetadata→nip01Core,nip30CustomEmojis(keep — existing commons name),nip72Communities→ nip72ModCommunities,nip78AppSpecific→nip78AppData,trustedAssertions→nip85TrustedAssertions(or keep the existing readablemodel/trustedAssertions— pick one and converge). state/stays generic. Account-specific state never goes here; it goes inmodel/account/*.state/is reusable load-a-thingStateFlowmachines.actions/= verbs,model/= nouns/state,cache/= the store. The 170Accountmethods become*Actionsfiles grouped by concern, takingsigner + cache + relayClient.keystorage/= persistence ports + actuals; the settings schema ismodel/account/settings. Schema (what) and persistence (how/where) are separate packages.service/name is source-set-agnostic. Pure services sit incommonMain, OkHttp/Jackson ones injvmAndroid— sameservice/<x>path, different source set (perARCHITECTURE.md§"Source sets").- Keep the name
LocalCachefor the engine class (222 amethyst files / 560 call-sites reference it); amethyst keeps a thinobject LocalCachedelegating to one sharedclassinstance during transition.