Adds two inner-event verbs on top of the existing kind:9 text send path:
amy marmot message react GID TARGET_EVENT_ID EMOJI
amy marmot message delete GID TARGET_EVENT_ID...
Both build an unsigned rumor per MIP-03 (RumorAssembler) and wrap it in
the usual kind:445 outer via MarmotOutboundProcessor — the same shape as
text messages, just a different inner kind. The target event is looked
up in the account's decrypted inner-message log (persisted by the
inbound pipeline) so the NIP-25 / NIP-09 templates can populate the
target's pubkey and kind tags without making the caller re-derive them.
Test 09 previously noted that react round-trip verification was blocked
on a missing CLI verb. Expanded it to react via amy and confirm B sees
the kind:7 surface in its messages stream.
https://claude.ai/code/session_01Unm6uLHGLj9UcBY7hWfJVW
Gives users a last-resort "nuclear" option when Marmot local state is
corrupted or otherwise unrecoverable — wipes every MLS group, retained
epoch secret, persisted KeyPackage bundle, subscription and in-memory
chatroom for the current account. Does not broadcast leave/SelfRemove
commits, since a graceful teardown may be impossible in exactly the
scenarios where users reach for this option. The KeyPackage is
republished lazily on the next sync so the account stays reachable.
Adds clearAllState() helpers on MlsGroupManager and
KeyPackageRotationManager, a resetAllState() orchestrator on
MarmotManager, an Account.resetMarmotState() entry point, and a
destructive row + confirm dialog in the AllSettingsScreen Danger Zone
that mirrors the existing Request-to-Vanish pattern.
https://claude.ai/code/session_01Unm6uLHGLj9UcBY7hWfJVW
When the local user leaves a Marmot MLS group, MarmotManager already
wipes MLS state, the relay subscription and the persisted message log,
but the chatroom (and its decrypted inner messages) lingered in
marmotGroupList until the UI happened to call removeGroup. The CLI
leave path skipped that step entirely, and removeGroup itself only
dropped the chatroom from rooms — it left noteToGroupIndex entries and
the chatroom's own messages set in place, so notes stayed strongly
referenced and kept appearing in the Notification feed.
Move the in-memory cleanup into Account.leaveMarmotGroup, and have
MarmotGroupList.removeGroup also clear the chatroom's messages and the
note→group index. LocalCache holds notes weakly, so cutting the strong
refs is enough — GC reclaims them and the existing acceptableEvent
filter (which keys off marmotGroupList.rooms) hides anything still
in-flight.
Replaces the two-row, mixed-control filter UI under the search bar with a
single segmented row for scope (All/People/Notes) and a Tune icon button
that opens a ModalBottomSheet containing Source, Follows-only, and Sort
controls. A primary-color dot on the icon indicates non-default filter
state.
Also renames SearchSortOrder.DEFAULT_EVENT/DEFAULT_PEOPLE to
EVENT_DEFAULT/PEOPLE_DEFAULT for consistency with EVENT_OPTIONS/
PEOPLE_OPTIONS, and routes the sheet's reset button through these
canonical constants so the UI default cannot drift from the ViewModel.
Android search previously dumped users, notes, hashtags, relays, and 3 channel
types into a single untoggled list and silently relied on NIP-50 relay search.
This adds a second-row control strip so users can pick what they're looking for
and how results are sourced/ranked.
Commons:
- New SearchScope { ALL, PEOPLE, NOTES } and SearchSource { LOCAL, RELAYS }
- SearchSortOrder: add POPULAR; EVENT_OPTIONS now [RELEVANCE, NEWEST, POPULAR]
Android SearchBarViewModel:
- scope / source / followsOnly / sortOrder StateFlows
- searchResultsUsers + searchResultsNotes respect scope, follows, and sort
- POPULAR sorts notes by Note.zapsAmount (descending)
- Follows-only filters authors (includes replies since filter is by author)
- directEventResolver: bech32 nevent/note/naddr -> Route.Note (auto-navigate)
- updateDataSource skips relay emit when source == LOCAL
Android SearchScreen:
- Second row: [All | People | Notes] SegmentedButton + Sort dropdown
- Third row: [Local | Relays] SegmentedButton + Follows-only chip
- bech32 hit triggers nav.nav(route) and clears the search
Desktop parity deferred - Desktop already ships an advanced panel that covers
most of this (kinds/authors/dates/hashtags/language/exclude terms + sort +
bech32 direct lookup). The new enums live in commons so Desktop can adopt.
Build note: gradle 9.3.1 can't be downloaded in this environment, so Android
and Desktop compilation were not verified here.
WhiteNoise threads its kind:445 payloads across three inner kinds:
kind:9 for chat, kind:7 for emoji reactions, kind:5 for unreacts. The
Amethyst ingest pipeline was routing every inner event into the group
chatroom feed, so a kind:7 reaction rendered as a chat bubble whose
only content was the emoji, quoting the liked message via the target
`e` tag. That looked identical to a threaded reply. The actual kind:9
reply from `wn messages send --reply-to` never arrived, which made the
two look swapped in the UI.
Three changes to untangle this:
- MarmotGroupList.addMessage/restoreMessage now skip inner events with
kind:5 and kind:7 before they enter `MarmotGroupChatroom.messages`.
The reaction is still consumed by LocalCache so it attaches to the
target note's reaction row, and the deletion still revokes that
reaction — they just don't appear as standalone bubbles.
- LocalCache.computeReplyTo learned to derive thread parents for
`ChatEvent` (kind:9) from plain NIP-10 `e` tags in addition to the
existing NIP-18 `q` tag path. WhiteNoise emits `e`-tagged replies;
without this the reply bubble had no quote context in the feed.
- tools/marmot-interop/marmot-interop.sh: `wn messages send` exposes
its `reply_to` field through clap v4, which renames snake_case to
kebab-case by default. The script was passing `--reply_to`, which
clap rejected; the `|| true` + redirected stderr hid the error and
no reply was ever published. Use `--reply-to`.
https://claude.ai/code/session_01K3g1uWLhByoEdBS77zdF32
Before, every Marmot group landed unconditionally under the Known tab of the
Messages screen while the New Requests tab ignored groups entirely, so an
invite from a stranger appeared next to real conversations. The dedicated
Marmot group list screen split by ownerSentMessage only, so unreplied groups
where an admin was followed still showed as New Requests.
Replace both splits with a shared MarmotGroupChatroom.isKnown(followingKeySet):
- user has replied -> Known
- no known members and no messages -> New Requests
- otherwise Known iff any admin is in the follow set
Wire the combined Messages feeds (ChatroomListKnownFeedFilter and
ChatroomListNewFeedFilter) through the helper, invalidate dmNew on group list
changes so empty groups flow into New Requests, and have
MarmotGroupListScreen observe kind3FollowList so newly followed admins move
groups between tabs immediately.
MIP-03 ("Application Messages" → Security Requirements) is explicit:
> "Inner events MUST remain unsigned (no `sig` field)
> This ensures leaked events cannot be published to public relays."
Authentication comes from (a) MLS framing — the sender's LeafNode
credential signs the outer MLS Application message — and (b) the
mandatory check in MarmotInboundProcessor.processPrivateMessage that
the inner event's `pubkey` equals the MLS sender's credential identity.
The Nostr Schnorr signature is redundant, and leaving it populated
means a leaked plaintext can be replayed as a valid public kind:9.
whitenoise-rs is spec-compliant (builds via `UnsignedEvent::new()` +
`ensure_id()`, never calls sign). Amethyst was the non-conforming side
on both directions:
1. Send path (`MarmotManager.buildTextMessage`,
`AccountViewModel.sendMarmotGroupMediaMessage`) called
`signer.sign(template)`, producing a signed rumor that shipped a
valid Schnorr signature through the encrypted channel. Switch both
to `RumorAssembler.assembleRumor` — same id derivation (SHA-256
over canonicalized [0, pubkey, createdAt, kind, tags, content]),
but `sig = ""`.
2. Restore path (`Account.kt` on startup reload of persisted inner
events) called `cache.justConsume(innerEvent, null, false)`, which
routed through `consumeRegularEvent` → `justVerify` → `event.verify()`
→ FAIL on empty sig → Note registered with `event = null`, message
never rendered. Pass `wasVerified = true`, matching what the live
receive path already does after the previous commit (573c5c2b).
Existing on-disk persisted messages from older signed-rumor builds
still load — `wasVerified=true` skips sig verify entirely, so both
legacy signed and spec-correct unsigned rumors deserialize cleanly.
The bug report: after tapping Remove on the Marmot Group Info screen,
the target npub stays in the member list, and the only log line is
the round-tripped kind:445 hitting the "Duplicate" branch in
GroupEventHandler.add. The author-side path (Account.removeMarmotGroupMember
→ MarmotManager.removeMember → syncMetadataTo → publish) has no logging,
so there's no way to tell which step broke — the MLS tree mutation,
the local member-list push to chatroom.members, or the UI recomposition.
Mirror the addMarmotGroupMember logging style on removeMarmotGroupMember
(entry, no-op guards, built commit id, publish) so we can see whether
the author path even runs end-to-end, and have syncMetadataTo log the
group id and the previous→new member count so the StateFlow update
becomes observable. No behavior change.
Reverts the SelfRemove-as-commit approach. openmls rejects a
commit whose only proposal is a SelfRemove from the committer with
RequiredPathNotFound — the spec model is that the removing member
publishes a STANDALONE proposal, and an admin receiver
(wn's mdk auto-commit path) folds it into their next commit.
Add `MlsGroup.buildSelfRemoveProposalMessage()` which frames
Proposal.SelfRemove as `MlsMessage(PublicMessage{proposal})` with
the proper FramedContent / signature / membership_tag and returns
the ready-to-publish bytes + the pre-commit exporter key for outer
encryption. Rewire `MlsGroupManager.leaveGroup` and
`MarmotManager.leaveGroup` to use it.
Target: test 11 (amy leave → B auto-commits SelfRemove →
removes amy from member list).
https://claude.ai/code/session_1469d7f4-bb66-4ffa-a44d-1dfa4b526484
Quartz's leaveGroup was returning `proposal.toTlsBytes()` and
publishing that raw on kind:445. That's not a valid MLS message
— it's just the proposal struct with no FramedContent / MlsMessage
framing — and even if it parsed, mdk/openmls would only STAGE the
proposal. The target never actually leaves the tree until someone
commits with that proposal.
MIP-03 explicitly allows non-admins to commit a SelfRemove-only
commit, so amy can produce a proper committable kind:445 herself
after the self-demote. Replace `selfRemove()` (returned raw bytes)
with `selfRemoveCommit()` which adds the proposal to the pending
set and runs the standard `commit()` path. Plumb the resulting
CommitResult through MarmotManager.leaveGroup so the outer
encryption uses `preCommitExporterSecret` (the epoch we're
leaving, which is the one every existing member still has).
This finally propagates amy's leave to B in test 11.
https://claude.ai/code/session_1469d7f4-bb66-4ffa-a44d-1dfa4b526484
Five protocol-level fixes and a batch of harness correctness fixes to get
the headless Marmot/Whitenoise interop harness from 1/13 to 5/13 passing
cleanly, with the remaining failures all rooted in wn's per-account
serial event-processor retry backoff (which drops undecryptable
pre-membership commits after several minutes) rather than amy behaviour.
quartz + commons
----------------
* MarmotGroupData: hold CURRENT_VERSION at 2. mdk-core (the Rust MLS
engine used by whitenoise-rs) strict-rejects v3 payloads with
`ExtensionFormatError("Trailing bytes in NostrGroupDataExtension")`
— our v3 welcomes and GCE commits never apply, so every cross-client
group flow dies at welcome processing. We still parse v3 happily on
the way in; we just don't emit it until mdk publishes the
forward-compat fix MIP-01 mandates.
* MarmotManager.updateGroupMetadata: MERGE extensions instead of
REPLACING. RFC 9420 §12.1.7 says GCE proposals blow away the old
extension list; callers that pass only [marmot_group_data] dropped
[required_capabilities], which peers then reject. Preserve every slot
except the one we're updating.
* MarmotManager.createGroup: new optional `initialMetadata` parameter
that bakes MarmotGroupData into epoch-0 GroupContext.extensions
directly. Without it, creators had to publish a pre-membership
"bootstrap" commit that no later joiner could decrypt — each such
peer then burned their retry budget on an undecryptable kind:445
before seeing the real state. Threaded through MlsGroup.create /
MlsGroupManager.createGroup.
* MarmotManager.mlsGroupIdHex: new translation helper so any code
juggling the MIP-01 nostr_group_id (what amy indexes on) and the
MLS GroupContext groupId (what mdk indexes on) can cross-reference
them without reaching into MlsGroupManager directly.
amethyst module
---------------
* Account.leaveMarmotGroup: self-demote before SelfRemove per MIP-01,
and promote a surviving member to admin first if the caller is the
sole admin (otherwise we'd throw "admin depletion"). Matches the
cli/GroupMembershipCommands.leave flow.
cli (amy)
---------
* Context.syncIncoming: don't advance `giftWrapSince` on empty polls
(so the first-ever sync doesn't bump the cursor past every
past-timestamped wrap we've ever been sent), subtract 2 days lookback
when filtering (NIP-59 randomWithTwoDays gift wraps can have any
createdAt in the last 48h), and only advance `groupSince` for groups
we actually received events for.
* Context.syncIncoming: after ingest, if any Welcome consumed a
KeyPackage, rotate and publish a fresh one immediately. MIP-00
requires this — a KP can only be welcomed once and leaving the
consumed one on relays just means later senders invite us with a
bundle we no longer have private keys for.
* Context.resolveGroupId: accept either nostr_group_id (amy's primary
key) or the MLS GroupContext groupId (what wn emits) on every verb
that takes a group id. Wired through GroupAdd/Remove/Leave/Metadata
/Read, Message send/list, and all the await* verbs so harness
scripts never have to juggle both forms for a single group.
* GroupCreateCommand: bake initial metadata into epoch 0 (see the
quartz change above). Dropped the now-redundant bootstrap commit
publish and tightened the JSON output to include `mls_group_id`.
* GroupMembershipCommands.leave: self-demote admin before SelfRemove;
promote an heir if we're the only admin, otherwise the GCE would
deplete admins and the leave aborts.
* MarmotIngest.ingestGiftWrap: unwrap the sealed-rumor layer. NIP-59
wrap is gift-wrap(kind:1059) → seal(kind:13) → rumor; the old code
only unwrapped once and then checked `inner.kind == 444`, which is
always false because inner is actually the seal. Unseal once more
before the Welcome check. This single fix is what unsticks every
amy-side Welcome ingestion.
wn harness patches + scripts
----------------------------
* whitenoise-defaults-env.patch: honour $WHITENOISE_DISCOVERY_RELAYS in
`Relay::defaults()` (release builds otherwise bake damus.io / primal
/ nos.lol into every new account's NIP-65 / Inbox / KeyPackage
lists, which breaks publishing and prevents the inbox subscription
plane from ever reaching an operational state in a sandbox).
* setup.sh: sleep 2s after amy's initial kind:30443 publish so
nostr-rs-relay has a chance to fsync before wn's first targeted
discovery query. Without it wn's `keys check` races the relay's
WAL flush and intermittently returns NotFound.
* lib.sh: peel wn's `{"result": …}` wrapper in `jq_group_id`,
`wait_for_invite`, `wait_for_message`, `wait_for_member`. Post-v0.2
wn `--json` output nests everything under `.result` (and
`groups invites[]` nests further under `.group.mls_group_id`) —
these helpers were still pattern-matching on the flat shape, so
they returned empty strings for a perfectly good response.
* tests-{create,manage,extras}.sh: track both group IDs per test
(amy's nostr + wn's MLS), pass each CLI the id it understands, and
bump the post-commit wait timeouts to 90–120s so wn's exponential
retry backoff has time to work through the pre-membership commits
it can't decrypt and get to the ones it can.
https://claude.ai/code/session_016kAxdp6ubB5CnF9URhCEzP
The notification service no longer creates its own relay subscriptions.
Instead, it relies on the AccountFilterAssembler subscription from the
Compose tree, which covers notifications, gift wraps, metadata, follows,
relay lists, and drafts. This ensures follow/mute list changes are
reflected in notification filtering.
Heavy feed subscriptions (Home, Video, Discovery, ChatroomList) now use
LifecycleAwareKeyDataSourceSubscription which subscribes on ON_START and
unsubscribes on ON_STOP. When the app backgrounds, these feeds pause and
their outbox relays disconnect. Only inbox and DM relays stay connected
via AccountFilterAssembler.
This prevents bandwidth waste on feeds nobody is viewing while the
always-on service keeps relay connections alive.
https://claude.ai/code/session_01LEPfmgGnwjB9a5SDFw5U8t
Move the "defaults a new Amethyst account gets seeded with" into commons
so the UI and amy agree byte-for-byte on what a fresh account looks like.
commons additions:
- commons/defaults/Constants.kt — relay URL constants (moved from amethyst/)
- commons/defaults/AmethystDefaults.kt — DefaultChannels, DefaultNIP65RelaySet,
DefaultNIP65List, DefaultGlobalRelays,
DefaultDMRelayList, DefaultSearchRelayList,
DefaultIndexerRelayList (moved from
amethyst/model/AccountSettings.kt)
- commons/account/AccountBootstrapEvents.kt — data class + bootstrapAccountEvents(signer, name)
that builds the nine events a new Amethyst account publishes: kind:0, 3,
10002, 10050, 10051, 10099, 50, 51, + channel list. One source of truth.
Retrofits:
- AccountSessionManager.createNewAccount now delegates event construction to
the commons helper; it still packages them into AccountSettings for the
Android storage path.
- DefaultSignerPermissions stays in AccountSettings.kt — it uses Android
NIP-55 Permission/CommandType types.
- 19 import updates across amethyst/ to point at the new commons paths.
New CLI verbs:
- amy create [--name NAME]: mint a keypair, write identity.json, seed
relays.json with the Amethyst defaults, publish all nine bootstrap events
to DefaultNIP65RelaySet via NostrClient.publishAndConfirmDetailed.
- amy login KEY [--password X]: accept any identifier form Amethyst's login
screen accepts — nsec1, ncryptsec (+ --password, NIP-49), BIP-39 mnemonic
(NIP-06), npub1, nprofile1, 64-hex pubkey (default) or 64-hex privkey
(with --private), NIP-05 (name@domain.tld, HTTP lookup). Read-only keys
land with privKeyHex=null; Identity now carries that cleanly.
https://claude.ai/code/session_01M6dCKAF5Y1VyHGZPjzwDXq
Five extractions so the Amethyst UI and the new amy CLI share one
implementation for each piece of Marmot/identity behaviour instead of
reimplementing it per platform.
quartz additions:
- MarmotGroupData.bootstrap(..) + withMergedRelays(..): factory for the
metadata shape every inviter stamps on a fresh group and the outbox-
merge rule every admin commit carries forward.
- KeyPackageFetcher: (a) pure fetchRelaysFor union of target kind:10051,
target outbox, my outbox; (b) one-shot fetchKeyPackage; (c) publish-
side resolveKeyPackagePublishRelays.
- resolveUserHexOrNull: single entry point for npub / nprofile / 64-hex
/ NIP-05 identifiers. Uses the existing Nip19Parser + Nip05Client
infra so NIP-05 resolution is live over HTTP.
commons additions:
- MarmotManager.leafIndexOf(..): pubkey -> leaf index lookup used by
every removeMember caller.
- MarmotManager.addMember(nostrGroupId, keyPackageEvent, relays):
convenience overload that lifts the base64 decode into the manager.
- MarmotManager.buildTextMessage(..) returning a TextMessageBundle;
optionally persists the outbound inner event (CLI needs persist,
Amethyst relies on LocalCache loopback).
- MarmotIngest.ingest(event): routes kind:1059 / kind:445 through
unwrap -> processWelcome / processGroupEvent -> persist the decrypted
application message. Deliberately platform-agnostic.
Retrofits:
- Account.addMarmotGroupMember and fetchKeyPackageAndAddMember now take
a KeyPackageEvent directly; Account.keyPackagePublishRelays delegates
to KeyPackageFetcher.
- AccountViewModel.updateMarmotGroupMetadata uses bootstrap + merged.
- AccountViewModel.sendMarmotGroupMessage builds the kind:9 template via
buildTextMessage(persistOwn = false) to avoid double-persisting.
- AccountSessionManager's NIP-05 login branch delegates to
resolveUserHexOrNull; no more hand-rolled Nip05Id / nip05Client.get.
- CLI Context exposes nip05Client and requireUserHex; syncIncoming now
calls MarmotManager.ingest; all command sites use KeyPackageFetcher
+ the shared identifier resolver. Npubs util deleted.
https://claude.ai/code/session_01M6dCKAF5Y1VyHGZPjzwDXq
Live streams routed through the Live Streams tab go through ShowVideoStreaming
→ ZoomableContentView, not LiveActivity.kt. ZoomableContentView is generic
and was calling VideoView without isLiveStream=true, so live stream segments
ended up in SimpleCache. The cached HLS manifest then went stale (live
manifests roll constantly) and playback stopped after ~30 seconds.
Plumb the flag through the data model: add isLiveStream to MediaUrlVideo,
set it true when ShowVideoStreaming constructs the model, and pass it from
ZoomableContentView into VideoView. URL heuristic (.m3u8) wasn't viable —
that would also bypass cache for on-demand NIP-71 multi-rendition videos
and defeat the original feature.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Critical:
- EmojiPackScreen delete dialog: swap plus icon for Delete
- EmojiGrid: switch emoji AsyncImage from Crop to Fit so non-square
artwork and transparent backgrounds render correctly
- AddEmojiDialog: wrap Address.parse in runCatching; a malformed
user-entered address no longer crashes the app
- BrowseEmojiSetsSubAssembler: use the query scope, not the account
scope, for the feed-freshness collector so the subscription lives
only as long as the query
High:
- Drop descriptive kdoc from AddEmojiDialog and EmojiPackViewModel
- EmojiPackCard takes an optional author; Browse feed always shows
it, MyEmojiList shows it only for foreign packs
- EmojiPackScreen: helper line 'Long-press to remove' above the grid
so the interaction is discoverable
- AddEmojiDialog: private toggle is now a Switch (correct control
for a boolean form value) instead of a FilterChip
Medium:
- EmojiPackState: distinct, accurate error messages on the add/
remove-to-selection paths (was copy-paste)
- OwnedEmojiPacksState: import NameTag/TitleTag rather than using
fully qualified names inline
Private emojis stored in the encrypted .content of kind 30030 EmojiPackEvents
were honest about being private but useless: they never appeared in the `:`
autocomplete or the reaction menu, even for the pack owner. This wires up
async decryption so pack owners actually see their private emojis everywhere
public ones already show.
- quartz: add EmojiPackEvent.publicEmojis() / privateEmojis(signer) /
allEmojis(signer) helpers, mirroring BookmarkListEvent's public/private
accessor split. privateEmojis() returns null for non-author signers, which
preserves the rule that foreign packs cannot expose private entries.
- commons: EmojiPackState.mergePack becomes suspend (mergePackWithPrivate)
and decrypts inside the existing combineTransform hot path; the StateFlow
consumers (account.emoji.myEmojis → EmojiSuggestionState) get private
entries automatically once decryption resolves. The combiner already runs
on Dispatchers.IO so the suspending decrypt does not block the UI.
- amethyst: RenderEmojiPack uses produceState to seed with the public list
immediately and replace with the merged list once allEmojis(signer)
resolves — keeps the reaction-menu gallery responsive.
- AddEmojiDialog explainer + KDoc no longer claim private emojis are
invisible; OwnedEmojiPacksState.toOwnedEmojiPack uses the new accessors.
- Test EmojiPackEventTest covers public/private/all access including the
foreign-signer case.
https://claude.ai/code/session_01SNG3nj8ZZDChggTsg1qznn
Two kinds of noisy warnings appear every time an invitee or a
restarted client catches up with events still sitting on the relays.
Both are actually expected, per-spec behavior — not errors.
(a) Outer ChaCha20-Poly1305 decrypt fails with "current and N
retained epoch key(s)".
A newly-joined member processes the three kind:445s that
advanced the group to their current epoch (add-me-commit plus
any earlier commits). The commits were outer-encrypted with
keys that predate their Welcome, so they never held them — this
is MLS forward secrecy working as intended. No state on the
receiver's side actually needs to update: they're already at
the post-Welcome epoch.
(b) "NO matching KeyPackageBundle for eventId=…" after app restart.
The gift-wrapped Welcome (kind:1059) is still on the relay and
gets redelivered on every subscription refresh. The client's
`processedEventIds` dedup set is in-memory, so after a restart
the Welcome flows all the way to `processWelcome`, which then
fails to find the KeyPackage bundle because it was consumed and
marked during the first processing.
Both of these fire every time a member opens the app, so the logs
currently look like something is broken even when everything is
behaving correctly.
Fix:
- Add `GroupEventResult.UndecryptableOuterLayer(groupId,
retainedEpochCount)`. `MarmotInboundProcessor.processGroupEvent`
and `applyCommit` now call the new `tryDecryptOuterLayer` (which
returns `ByteArray?` instead of throwing) and surface this
variant when every available key fails. `GroupEventHandler.add`
logs it at DEBUG with a one-liner noting "likely from before our
join".
- Add `WelcomeResult.AlreadyJoined(nostrGroupId)`. When
`MarmotInboundProcessor.processWelcome` is called with a
`hintNostrGroupId` we're already a member of, short-circuit
before the KeyPackageBundle lookup and return `AlreadyJoined`.
`processMarmotWelcomeFlow` logs at DEBUG.
- `MarmotManager.processGroupEvent` treats `UndecryptableOuterLayer`
as a no-op for subscription-timestamp updates (same as
Duplicate/Error/CommitPending), so a unreadable past-epoch event
doesn't move the group's `since` forward.
No behavioral changes beyond logging level and the short-circuit of
a Welcome we already processed (which was throwing and returning
Error before). Existing callers that only care about successful
`Joined` / successful `CommitProcessed` / `ApplicationMessage`
branches are unaffected — the two new variants join the
`Duplicate` / `CommitPending` / `Error` quiet side of the sealed
class.
https://claude.ai/code/session_014zfdNeeKAfU1zGGyFw4bUL
The snapshot/restore machinery added in the previous commit
(ca988b7) to implement MDK-style `stageCommit` + `mergeStagedCommit`
introduced a tree-rebuild path (RatchetTree.decodeTls followed by a
fresh SecretTree construction) that, in production, caused a
recursive StackOverflowError in `SecretTree.getNodeSecret` during
encryption after a second add-member cycle. The unit test flow did
not exercise it and couldn't reproduce.
Keep the underlying fix — outer-encrypt outbound kind:445 commits
with the PRE-commit (epoch-N) exporter secret — but deliver it with
far less machinery:
- `CommitResult.preCommitExporterSecret` now carries the
`MLS-Exporter("marmot", "group-event", 32)` value captured inside
`MlsGroup.commit()` BEFORE any state mutation. It's the key
existing members still at epoch N hold, so the outer kind:445
wrap with it is decryptable to them (RFC 9420 §12.4 + MDK parity).
- `MlsGroup.commit()` captures this exporter once at the top of the
function, threads it into the returned `CommitResult`.
- `MarmotManager.addMember / removeMember / updateGroupMetadata`
read `commitResult.preCommitExporterSecret` and pass it to
`MarmotOutboundProcessor.buildCommitEvent(..., exporterKey=...)`.
The eager `group.addMember` / etc. continue to advance the local
epoch immediately — the same behavior as before the previous
commit, which was proven safe under production load.
Removed:
- `MlsGroup.stageCommit` / `mergeStagedCommit` / `stageAddMember` /
`stageRemoveMember` and their snapshot/restore helpers.
- `MlsGroupSnapshot` / `StagedCommit` types.
- `MlsGroupManager.stageAddMember` / `stageRemoveMember` /
`stageRotateSigningKey` / `stageUpdateGroupExtensions` /
`mergeStagedCommit`.
- `tree` back to `val` in `MlsGroup`.
Kept from the previous commit:
- `MarmotOutboundProcessor.buildCommitEvent(... exporterKey: ByteArray?)`
with default falling back to `groupManager.exporterSecret(...)`.
- `MlsGroupManager.processCommit` retains epoch secrets AFTER
successful apply (was a secondary bug polluting the retention
window with duplicates of the current key when a commit failed).
- `pushRetainedEpoch` helper that accepts a pre-captured
RetainedEpochSecrets, used throughout instead of the old
`retainEpochSecrets(nostrGroupId, group)`.
Test updated:
- `testCreatorCanAddTwoMembersAndAllDecryptFollowingMessage` now
uses the eager `addMember` path and asserts that the member still
at the old epoch can outer-decrypt with the pre-commit key shipped
back via `CommitResult`. Also decrypts Alice's self-echo and
sends multiple follow-up messages — this is the scenario that
triggered the production stack overflow before the revert.
- `testAddMemberExposesPreCommitExporterSecret` (renamed) confirms
`CommitResult.preCommitExporterSecret` equals the exporter the
group had immediately before the call, and that the post-commit
exporter differs from it (proves we actually rotated).
https://claude.ai/code/session_014zfdNeeKAfU1zGGyFw4bUL
Per RFC 9420 §12.4 and the MDK reference implementation, a kind:445
Commit MUST be outer-encrypted with the epoch-N exporter secret — the
key existing members still hold — so that they can decrypt, process the
commit, and advance to N+1. Amethyst was instead encrypting with the
epoch-(N+1) key because MlsGroupManager.addMember / removeMember /
updateGroupExtensions applied the commit locally *before* handing the
bytes to MarmotOutboundProcessor.buildCommitEvent, which read the
current (post-commit) exporter via groupManager.exporterSecret.
Observed in a 3-party Amethyst-to-Amethyst test (David creates group,
adds Eden then Fred, sends "Hi"): Eden joined at epoch 1 via Welcome,
then "succeeded" in outer-decrypting the add-Eden kind:445 with her
own post-commit key but failed to apply ("Duplicate encryption key:
leaf 1") because she was already in the tree from the Welcome. Eden
never advanced past epoch 1, so Fred's subsequent join and David's
application message were undecryptable. Fred, joining directly at
epoch 2, saw the Hi message fine — matching the symptom where the
last-added member is the only one who can read new messages.
Fix splits commit creation from commit application on the outbound
path, mirroring OpenMLS / MDK's `create_commit` + `merge_pending_commit`
pair:
- MlsGroup gains stageCommit() / mergeStagedCommit(). stageCommit
captures a full snapshot, runs the existing commit logic (which
mutates in place), captures the post-commit snapshot, then restores
the pre-commit snapshot so the caller observes epoch N until merge.
The returned StagedCommit carries the pre-commit exporter secret
and the framed commit bytes. stageAddMember / stageRemoveMember
are thin wrappers that propose + stage.
- MlsGroupManager exposes stageAddMember, stageRemoveMember,
stageRotateSigningKey, stageUpdateGroupExtensions, and
mergeStagedCommit. The eager addMember/removeMember/commit/etc.
entry points are retained (documented as test-only) so the MLS
unit tests in MlsGroupTest, MlsGroupLifecycleTest, etc. keep
working unchanged.
- MarmotOutboundProcessor.buildCommitEvent takes an optional
exporterKey override; callers pass StagedCommit.preCommitExporterSecret.
- MarmotManager.addMember / removeMember / updateGroupMetadata now
stage → build kind:445 → markEventProcessed → mergeStagedCommit.
- MlsGroupManager.processCommit was also retaining epoch secrets
*before* calling group.processCommit, so a failed commit (such as
the add-me relay echo that the new member can't apply) polluted
the retained window with a duplicate of the current epoch key.
Now retain only on success.
Regression tests:
- testStagedAddMemberUsesPreCommitExporter: asserts stage does not
advance the epoch and that preCommitExporterSecret equals the
pre-stage exporter output.
- testCreatorCanAddTwoMembersAndAllDecryptFollowingMessage: end-to-end
David/Eden/Fred reproduction — creator adds two members in sequence
and publishes an application message; both members must decrypt it.
Fails without the fix, passes with it.
https://claude.ai/code/session_014zfdNeeKAfU1zGGyFw4bUL
Address the architectural critique from self-review. The leaderboard
math and the shared system card are now platform-neutral primitives in
commons, the on-UI-thread aggregation is gone, and two latent
correctness bugs around subscription lifecycle and zap routing are
fixed.
- Introduce a pure LiveActivityTopZappersAggregator in commons that
takes plain ZapContribution values and returns a sorted, deduped,
anon-bucketed top-N list. Covered by a unit-test suite that is
Compose/Android-independent.
- Introduce LiveStreamTopZappersViewModel in commons/commonMain. It
owns two partitioned contribution maps (stream-scoped and
goal-scoped), mutates them under a Mutex on the IO dispatcher in
response to channel.changesFlow and Note.zaps.stateFlow emissions,
and publishes the aggregator result via a StateFlow<List<TopZapperEntry>>.
UI is now a dumb consumer with no aggregation logic left in the
composable.
- Move StreamSystemCard to commons/commonMain/compose/ so Desktop can
consume it alongside Android.
- Fix attachZapToLiveActivityChannel to run even when a zap receipt was
already consumed by another subscription. Previously a zap first seen
by notifications/profile would never reach the stream's channel
cache; addNote is already idempotent so this is a safe retro-route.
- Fix ChannelFilterAssemblerSubscription to re-invalidate filters when
a live-activity channel's metadata arrives. The 30311 stream event
usually lands after the initial assembler run, so the goal-tag-driven
subscription never fired. Now it re-runs as metadata resolves and the
goal id is discovered.
https://claude.ai/code/session_01WJA5PvDABegBYUu6h42YZi
addMember/removeMember/updateGroupMetadata were publishing the raw TLS
Commit struct inside the kind:445 outer ChaCha20 layer, so receivers
that started with MlsMessage.decodeTls read the first two bytes of the
Commit as the MLS protocol version and aborted with "Unsupported MLS
version: <garbage>" (e.g. 16704 = 0x4140).
Wrap commits produced by MlsGroup.commit in a PublicMessage carrying
the sender leaf index and confirmation_tag, then in an MlsMessage
envelope, and expose those bytes via CommitResult.framedCommitBytes so
MarmotManager uses them instead of the raw commit bytes. The internal
CommitResult.commitBytes field stays raw so MlsGroup.processCommit and
existing tests that exercise it directly keep working.
Also mark the committer's own published kind:445 as already processed
in MarmotInboundProcessor so that the relay echo of our own commit is
treated as a duplicate rather than re-applied on top of the already-
advanced local epoch.
https://claude.ai/code/session_01NR6JgYRimVb142T2nkChuh
Previously ChatroomListKnownFeedFilter dropped any group whose
newestMessage was null, so groups the user just created and groups
received via Welcome events without any activity yet never appeared
in the Messages list.
Emit a stable placeholder Note (carrying the chatroom as a gatherer)
per empty group, route it through the existing Marmot row path, and
invalidate dmKnown on MarmotGroupList.groupListChanges so newly added
or promoted groups rebuild the feed.
Replaces the comma-separated relay text on the Marmot Group Info
screen with a FlowRow of 55dp relay avatars that open RelayInfo on
tap and copy the URL on long-press, matching the relay-icon pattern
used elsewhere in the app.
To surface whether each configured relay is actually carrying traffic
for the group, MarmotGroupChatroom now tracks the most recent
kind:445 event timestamp observed from each delivering relay and the
info screen renders a green dot when a relay has delivered a group
event within the last 7 days (gray otherwise).
The "h" tag is optional in MIP-02 Welcome events — senders like
whitenoise-rs omit it. Instead of failing when the h-tag is absent,
derive nostrGroupId from the NostrGroupData extension embedded in the
Welcome's GroupContext (the authoritative MLS source). An h-tag hint,
if present, is still validated against the MLS-derived value.
Expose the member list as MutableStateFlow<List<GroupMemberInfo>> on
MarmotGroupChatroom, populated by MarmotManager.syncMetadataTo alongside
the existing memberCount. MarmotGroupInfoScreen and RemoveMemberScreen
now observe it via collectAsStateWithLifecycle, so both remote commits
(already routed through syncMetadataTo in DecryptAndIndexProcessor) and
local mutations refresh the UI without manual re-queries.
Account.addMarmotGroupMember and removeMarmotGroupMember now call
syncMetadataTo right after the MLS state is mutated, mirroring the
pattern already used by updateMarmotGroupMetadata, so the local change
is visible immediately without waiting for the relay round-trip.
The reference port recomputed the inverse DCT cosine tables once per output
pixel — for a 32x24 decode that's ~43k redundant cos() calls. This change
lifts the tables out of the inner loop and caches them across decodes
keyed by (size, componentCount) so subsequent placeholders at the same
target size skip cosine evaluation entirely.
Additional wins:
- AC coefficients unpack into pre-sized DoubleArrays (no ArrayList<Double>
boxing, no post-hoc copy)
- truncated hashes are rejected up-front via a single length check instead
of mid-stream
- LPQA -> sRGB uses an inline branch clamp instead of a
min/max/round/coerceIn chain
Tests: 12 unit tests cover determinism across repeated decodes, cache
clearing, alpha preservation, aspect-ratio preservation both directions,
average-color drift bounds, truncated input rejection, and round-tripping
base64 vs. raw bytes. All green.
Bench: new ThumbHashBenchmark exercises opaque decode, alpha decode,
warm- and cold-cache decode, aspect-ratio probe, and the full
decodeKeepAspectRatio pipeline so the perf delta is visible on device.
Adds a parallel ThumbHash placeholder everywhere BlurHash is already used.
Remote events now carry both hashes; renderers prefer thumbhash when
available and fall back to blurhash. The MIP-04 `thumbhash` imeta field
that was previously reserved-but-unused is now wired end-to-end.
- New ThumbHash encoder/decoder in commons/commonMain (no new Gradle dep)
- New ThumbhashTag and thumbhash accessors/builders across NIP-17, 68,
71, 94, 95, 99, the experimental profile gallery, and MIP-04
- RichTextParser + MediaContentModels carry a thumbhash field alongside
blurhash so every downstream composable can pick its preferred placeholder
- New ThumbHashFetcher (Android + Desktop) registered with Coil, plus a
small placeholderModel(thumbhash, blurhash) helper centralising the
"prefer thumbhash, fall back to blurhash" rule
- Rename BlurhashMetadataCalculator -> PreviewMetadataCalculator; the
calculator decodes the bitmap / video thumbnail once and runs both
encoders on the same pixels to keep upload cost flat
- DesktopMediaMetadata, MediaUploadResult, and FileHeader now surface
both hashes through the NIP-96, Blossom, NIP-95, MIP-04, NIP-17 DM,
Classifieds, picture, video, and long-form upload paths
- Round-trip unit test for the ThumbHash port
Adds MediaUrlPdf / PdfSegment to the rich-text pipeline so PDF URLs
(detected by .pdf extension, NIP-92 imeta m tag, or application/pdf
Content-Type) render a card showing the first page, filename, and page
count. Tapping opens a full-screen HorizontalPager over every page
rendered on demand with PdfRenderer. Long-press surfaces the existing
share menu. Uses only the built-in Android PdfRenderer; no new
dependencies. Desktop continues to fall back to a clickable link.
Adds a dismissible top-of-screen banner to the Pinned Notes, Bookmarks,
Old Bookmarks, and Bookmark Set (detail) screens that appears when one
or more listed items have been deleted by their author (kind-5 deletion,
pubkey-matched via DeletionIndex). Tapping "Remove from list" rewrites
the underlying list event (NIP-51 kind 10001/10003/30001/30003) with
the deleted entries stripped out and broadcasts it once.
The scan is scoped to each screen's loaded list (typically <20 items)
and runs only while composed, so it avoids the performance hit of
hooking Account.deletedEventBundles, which fires for every kind-5
deletion in the firehose. Public and private (NIP-44 encrypted)
bookmarks are both cleaned in a single resign() call per list.
Root cause of "ringing survives reject on old Android":
1. startRinging() was NOT idempotent. When CallManager re-emitted
IncomingCall state (e.g., when another group member rejected while
still ringing), the state collector called startRinging() again.
startRingtone() OVERWROTE the ringtone field reference without
calling stop() on the old one. Old Ringtone instances kept
playing with no reachable reference — stopRingtone() on reject
only stopped the latest one.
2. Ringtone.isLooping was only set on API 28+. On older Android the
Ringtone class had documented reliability problems with stop().
MediaPlayer has reliable stop/release on all API levels and
supports looping universally.
Fixes:
- Switch from android.media.Ringtone to android.media.MediaPlayer.
- Make startRinging() idempotent — always call stopRinging() first
so any existing player/vibrator is torn down before a new one
starts.
- Add aggressive tracing logs throughout the reject path:
* CallAudioManager: instance id + thread on every start/stop,
MediaPlayer error listener, before-and-after player hashes.
* CallSession: log every state collector tick, log close() entry.
* CallManager: log rejectCall() entry/exit + transitionToEnded.
* CallNotificationReceiver: log every action with state transitions.
With these logs, if ringing still survives reject we can trace
exactly which primitive is failing.
https://claude.ai/code/session_019yNnDjGKmJb19gadmojq54