Files
amethyst/commons/plans/2026-05-30-amethyst-to-commons-migration.md
Claude 6579b5f656 docs: audit, status-stamp, and index all module plans
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
2026-06-30 15:35:38 +00:00

20 KiB
Raw Permalink Blame History

Amethyst → Commons Migration Plan

Status: queued — the keystone Phase A is untouched: no Account.kt/LocalCache.kt engine in commons, the amethyst monoliths remain (3757 + 4092 LOC), and commons/model/account still holds only the pre-existing AccountInfo/AccountStorage/SignerType scaffolding 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/main1817 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 concrete Account.kt (3565 LOC) and LocalCache.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) and relays/*.
  • Formatters / util: util/PubKeyFormatter, util/NumberFormatters, util/PlatformNumberFormatter (expect/actual). amethyst's ui/note/PubKeyFormatter.kt is a duplicate.
  • Services: service/lnurl/{LightningAddressResolver,OkHttpLnurlEndpointResolver}, service/nwc, service/upload. amethyst's service/lnurl/LightningAddressResolver.kt is 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 for R.string, NOT a custom StringProvider.
  • Image loading: Coil 3 is already KMP — coil.compose in commonMain, coil.okhttp in jvmAndroid. Shared composables can call AsyncImage directly; 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.xRes.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: okhttpjvmAndroid, 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) ~5070% 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; GenericLoadableui/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)

  1. service/relayClient (Android) vs commons/relayClient — keep Android as wiring only; lift cache-independent EOSE managers down.
  2. service/lnurl/LightningAddressResolver vs commons/service/lnurl/… — one canonical copy.
  3. ui/note/PubKeyFormatter vs commons/util/PubKeyFormatter — delete the dup.
  4. ui/theme vs commons/ui/theme — single color/type source.
  5. ui/dal vs commons/ui/feeds — delete re-export shims.
  6. service/relays/EOSE* scattered — one EOSE-state home.

6. Phased roadmap

  • Phase A — Account keystone (largest, gates the rest). Extract Account + LocalCache business logic into commons/model/account + model/cache behind 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 nipNN model packages, topNavFeeds, cashu, broadcast, ai, previews, EOSE/lnurl/PubKeyFormatter dedups, okhttpjvmAndroid.
  • Phase C — storage/network seams. keystorage expect/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 of ui/note/creators and 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.25.5) + a bulk move of 46 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 themamethyst (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:

  1. Decompose the monoliths; don't relocate them. Account (28 StateFlows + ~170 suspend funs) splits into state (per-user reactive state), actions (the verbs), and a thin façade implementing IAccount. LocalCache becomes the cache engine as a class (so every platform gets multi-account; Desktop already needed it).
  2. Litmus test = CLI-constructibility. signer → cache → account state → actions → relay client → pure services must build in a headless main() 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)

  1. model/nipNN<slug> (raw list state) vs model/account/* (composed view). model/nip02FollowList holds the raw Kind-3 list; model/account/follows holds the user's derived per-feed follow flows that compose it. Same for relays (nip65RelayList vs account/relays) and mutes (nip51Lists vs account/hidden).
  2. 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 readable model/trustedAssertions — pick one and converge).
  3. state/ stays generic. Account-specific state never goes here; it goes in model/account/*. state/ is reusable load-a-thing StateFlow machines.
  4. actions/ = verbs, model/ = nouns/state, cache/ = the store. The 170 Account methods become *Actions files grouped by concern, taking signer + cache + relayClient.
  5. keystorage/ = persistence ports + actuals; the settings schema is model/account/settings. Schema (what) and persistence (how/where) are separate packages.
  6. service/ name is source-set-agnostic. Pure services sit in commonMain, OkHttp/Jackson ones in jvmAndroid — same service/<x> path, different source set (per ARCHITECTURE.md §"Source sets").
  7. Keep the name LocalCache for the engine class (222 amethyst files / 560 call-sites reference it); amethyst keeps a thin object LocalCache delegating to one shared class instance during transition.