MIP-03 inner events inside a kind:445 GroupEvent are unsigned (sig is
empty or placeholder), so LocalCache's `justVerify()` fails and
`consume(ReactionEvent)` skips the branch that attaches the reaction to
its target note via `addReaction()`. The same applies to kind:5
deletions. Side-channel inner events from other Marmot clients
(WhiteNoise in particular) were therefore silently dropped by
LocalCache, so an inbound emoji reaction never appeared on the target
chat bubble.
NIP-17 has the same "unsigned inner rumor" shape and already hands the
inner event to `justConsume(..., wasVerified = true)`. Align Marmot with
that contract at the two inbound call sites:
- `GroupEventHandler.add` (fresh kind:445 from relay)
- `Account` restore-from-disk loop (persisted plaintext at app start)
Authenticity is already guaranteed by the MLS layer:
`MarmotInboundProcessor.processPrivateMessage` rejects any inner event
whose `pubkey` doesn't match the MLS sender credential identity before
ever emitting an `ApplicationMessage`.
https://claude.ai/code/session_01KhVtLJAEkNCwpRWUUTqDFM
Adds a star toggle next to each member in MarmotGroupInfoScreen that
admins can use to promote a non-admin to admin or demote a fellow
admin. Both paths show a confirmation dialog and publish a
GroupContextExtensions commit that rewrites admin_pubkeys. The toggle
is hidden for non-admin viewers and for the signer's own row (self
demotion must go through the leave flow). The demote button is also
hidden when the member is the only remaining admin so we never hit the
MIP-03 admin-depletion guard in MlsGroup at commit time.
Account.grantMarmotGroupAdmin / revokeMarmotGroupAdmin reuse
updateMarmotGroupMetadata so the new admin list ships alongside the
signer's outbox relays, keeping the canonical relay set consistent
with other metadata commits.
The first pass only fixed `group add`. Auditing the rest of the CLI
against MIP-00..03 turned up three more spots where `amy` either
queried the wrong relays or silently skipped a required advertisement:
1. `marmot key-package check <npub>` used `anyRelays()` — i.e. the
inviter's configured relays — so checking for a KeyPackage on a
user who advertises `kind:10051` somewhere we don't know about
always returned `not_found`. Now runs RecipientRelayFetcher against
bootstrap seeds and fetches from the union of (target kind:10051,
target kind:10002 write, bootstrap). Emits `found_on` so callers
can see which relay served the hit.
2. `await key-package <npub>` had the same bug inside the poll loop.
Resolved once up front, then the loop fetches from the target's
advertised relays every tick. Throws an `AwaitTimeout` early if no
relays can be discovered at all, instead of silently polling void.
3. `relay publish-lists` published kind:10002 + kind:10050 but never
kind:10051. Per MIP-00 the KeyPackage Relay List is how other
Marmot clients discover where our KPs live — without it the
`key_package` bucket on disk is invisible to anyone else. Now also
publishes kind:10051; falls back to the NIP-65 set if the bucket
is empty so we never advertise an empty list. (`amy create`
already publishes it via AccountBootstrapEvents.)
Docs: cli/README adds a "Relay routing" section that lists the exact
relay set used for publish vs fetch of every Marmot event kind, plus
the bootstrap-pool definition, so agents + interop-test authors can
reason about cross-user reachability without reading the code.
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
`amy marmot group add` previously sent the Welcome gift wrap to the
inviter's own kind:10050 (ctx.inboxRelays()) and fetched the invitee's
KeyPackage from only the inviter's configured relays. Two users with
disjoint relay configurations could never successfully marmot each
other — the welcome landed somewhere the invitee never polled.
Mirror the Android Account.addMarmotGroupMember flow in the CLI:
- New RecipientRelayFetcher in quartz/marmot one-shot-drains a target
user's kind:10050, kind:10051 and kind:10002 from a seed relay set.
Replaceable-event semantics: newest created_at per kind wins. Guards
against relays echoing events authored by someone else.
- Context.bootstrapRelays() unions the inviter's configured relays with
AmethystDefaults (DefaultNIP65RelaySet + DefaultDMRelayList) so we can
discover strangers even when nothing overlaps with our own config.
- GroupAddMemberCommand now per-invitee: looks up their relay lists;
passes kind:10051 + kind:10002 write + bootstrap to KeyPackageFetcher;
publishes the welcome to kind:10050 (fallback to NIP-65 read, then
DefaultDMRelayList, then our outbox as belt-and-braces).
Group commits/messages (kind:445) continue to use the group's MIP-01
relay set — those were already correct. Exposes welcome_targets and
key_package_relays in the JSON output so callers can verify routing.
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.
Move the add-member search to a fixed position at the bottom of the screen
(matching the New Short Note pattern) so it stays visible regardless of how
far the user has scrolled through a large member list. Suggestions now render
above the input, and each suggestion row shows an explicit PersonAdd icon
button so the add action has a clear visual affordance.
Adds an optional trailingContent slot to ShowUserSuggestionList / UserLine
so callers can attach per-row actions without forking the component.
https://claude.ai/code/session_01JbRycm4g3LrqatnCVWVdGh
After creating a Marmot group, the Create Group screen stays on the
back stack, so pressing back from the new group chat returns to the
empty creation form instead of the Messages screen. Swap `nav.nav`
for `nav.popUpTo(..., Route.CreateMarmotGroup::class)` so the creation
screen is removed inclusively as we navigate into the group.
The bug (re-added user rejoins without a usable own_leaf, so they
can neither decrypt new group messages nor send) reproduces entirely
with two in-process MlsGroup instances — no relay or second Nostr
client needed. A pure Quartz unit test runs in seconds and catches
the regression deterministically, where the bash interop version
depended on whitenoise-rs, wnd daemons and a local relay.
- quartz: add MlsGroupLifecycleTest.testReaddAfterRemove_RejoinerCanEncryptAndDecrypt
covering create → add → round-trip → remove → re-add with a fresh
KeyPackage → round-trip again, asserting leafIndex and both
encrypt/decrypt directions on the rejoined instance.
- interop: drop test_17_readd_after_remove from the headless suite and
its registration, since the unit test supersedes it.
https://claude.ai/code/session_01JaeqZwVNLKvUUvXWRzPmRP
After leaving a Marmot group the user was dropped on the group list;
send them to the main Messages tab instead, which is the correct
starting point for picking another conversation.
https://claude.ai/code/session_01JaeqZwVNLKvUUvXWRzPmRP
- Fold the Remove Member screen into the info screen: each member row
now has an inline remove icon that opens a confirmation dialog.
- Fold the Add Member screen into the info screen: a search field with
a user-suggestion list lives directly above the member list, so
adding a member never requires leaving the screen.
- Move Leave Group out of the scrolling body into a top-bar action so
it is reachable regardless of member count.
- Shrink the relay strip (35dp tiles with a small activity dot) and
tuck it next to the group header; drop the "Relays" label and the
MLS epoch readout, which were visual clutter.
- Route the chat screen's Add Member button to the info screen and
delete the two standalone screens and their routes.
- Add headless interop test 17: A creates a group, adds B, removes B,
re-adds B, and verifies B can both receive and send messages. Guards
the reported regression where a re-added member came back without a
usable leaf and silently lost the ability to post.
https://claude.ai/code/session_01JaeqZwVNLKvUUvXWRzPmRP
When the user updates their relay configuration on the AllRelay screen,
the newly-selected relays often have no copy of the account's existing
settings (profile, follow list, mute list, other relay lists, etc.), so
the user appears to "lose" their account to anyone reading from those
relays until the next time each list is edited.
After saving each relay list, re-publish the relevant existing events
to the newly-selected relays (only when the set actually differs):
- Outbox/inbox (NIP-65) or private-storage or broadcast changes
republish every known account-settings event to the new relay set.
- KeyPackage relay-list changes republish all active kind:30443
KeyPackage events authored by this account.
- Local relay-list changes republish account-settings events locally.
- Indexer relay-list changes publish kind:0 and kind:3.
- Search relay-list changes publish kind:0.
Local relays now flow through a new Account.saveLocalRelayList so the
republish hook lives alongside every other relay-list save.
https://claude.ai/code/session_01PcvDaNyzT6Tk4D7jn75Jbm
The three Marmot stores (MLS group state, messages, KeyPackage bundles)
were all initialized with the global app filesDir, so chats and MLS
state from one account were visible to any other logged-in account.
Pass each store a per-account subdirectory (accounts/<pubkey>) derived
from the signer pubkey, so paths become:
files/accounts/<pubkey>/mls_groups/<groupId>/{state,retained,messages}
files/accounts/<pubkey>/marmot_keypackages/state
Existing in-memory state was already scoped per-Account; this closes the
last remaining cross-account leak on disk.
The DisappearingScaffold hid its top/bottom bars purely from overscroll
gestures, so a feed with too few items to scroll would end up with two
empty bands where the chrome used to be. Pure overscroll (consumed.y == 0)
from the fully-visible state is now ignored; once the bars have started
moving we know the list is scrollable and edge overscroll keeps feeding
bar motion as before.
The interactive harness was failing with the same
KeyringEntryMissingForExistingDatabase error as the headless one —
the MLS SQLite DB on disk references a keyring entry that no longer
exists (keychain entry pruned, data dir restored out of band, or a
previous run used the mock keyring). wnd can't open the DB in that
state, so the daemon exits before its socket appears.
Unlike the headless harness (which wipes unconditionally because its
mock keyring is always ephemeral), the interactive harness uses the
real OS keyring and benefits from preserving B/C identities across
runs. So: try once, and only wipe + retry when stderr actually shows
the keyring-missing error. Also dump stderr/stdout tails inline on
final failure so the operator doesn't have to chase a log path.
Apply the same SIGINT/SIGTERM/SIGHUP trap hardening to the interactive
harness that just landed on the headless one. Without it, killing the
script mid-run leaves the nohup'd wn daemons alive and the next run
fails in preflight because the sockets are still held.
The previous trap only fired on the EXIT pseudo-signal, which doesn't
always run cleanly when the script is killed mid-flight — leaving the
nohup'd wnd daemons (and the local relay) alive and still holding the
port. Route SIGINT/SIGTERM/SIGHUP through an explicit `exit`, use a
single idempotent cleanup handler, and preserve the exit code so the
harness still reports failure when killed.
The headless harness runs wnd with WHITENOISE_MOCK_KEYRING=1, but the
mock keyring is in-memory only — it starts empty on every wnd restart.
Meanwhile the SQLite databases under $data_dir persist across runs and
reference keys that no longer exist, so the second run onward fails at
startup with KeyringEntryMissingForExistingDatabase before wnd can even
open its control socket. Wipe everything under the daemon's data dir
(except logs and pid) before each start so wnd always comes up cold and
consistent with its ephemeral keyring.
Also surface failure diagnostics inline instead of burying them in a log
file path: print the last 40 lines of stderr and 20 of stdout, report
whether wnd is still alive or already exited, and break out of the 30s
wait loop the moment the daemon dies.
The prior change tracked the "no slide" intent via a mutable flag on Nav
that every navigate method had to reset. Replace it with a per-entry
hint: navBottomBar writes SKIP_SLIDE_ANIMATION_KEY=true to the new
destination's savedStateHandle, and composableFromEnd reads it off
targetState (forward) / initialState (pop).
Benefits:
- No shared mutable state; no reset bookkeeping in nav/newStack/popUpTo.
- No race between interleaved navigate calls.
- Pop animations are automatically consistent with how the entry was
entered - backing out of a bottom-bar tab now also fades.
Drawer routes are registered with composableFromEnd (slide-from-right +
scale-out) because that matches the expected drawer push animation.
When a user pins one of those routes onto the bottom navigation bar
(feature added in 376e404c), the bottom-bar tap still replayed the
slide animation because the transition is bound to the Route type at
composable<> registration time, not to the caller.
Introduce a third navigation verb, INav.navBottomBar(route), that sets
a skipSlideAnimation flag on Nav before performing the same newStack-
style stack reset (popUpTo + launchSingleTop). composableFromEnd /
composableFromEndArgs read the flag in their enter/exit/popEnter/
popExit lambdas and return null when set, inheriting the NavHost-level
fade (matching what the five default bottom-bar routes already do).
nav.newStack behaviour and all drawer navigation paths are unchanged,
so non-bottom-bar callers (intent redirects in AppNavigation.kt and
every DrawerContent click) keep their slide.
Two drifts surfaced as 7 failing tests on the jvmTest run:
1. MarmotGroupData CURRENT_VERSION is held at 2 for mdk-core interop
(see the const-doc block), but three tests still assumed v3:
- marmotGroupData_defaultVersionIsThree asserted the constant directly.
- marmotGroupData_roundTripWithDisappearingSecs and
buildGroupEvent_appendsExpirationTagWhenDisappearingConfigured relied
on the default version emitting the v3-only disappearing_message_secs
field, which the encoder correctly skips for v2.
Rename the first to `currentVersionIsHeldAtTwoForMdkInterop` with a
spec-cross-reference comment and an extra assertion that v3 is still
in SUPPORTED_VERSIONS. Pin version=3 explicitly in the other two so
they exercise the v3 path regardless of the interop hold-back.
2. MlsGroup.processCommit now requires a non-empty FramedContentTBS
signature on non-external commits (per RFC 9420 §6.1, introduced in
0c970945). Four pipeline tests still called the raw processCommit
overload with signature=ByteArray(0), so they all failed with
"FramedContentTBS signature missing on commit from leaf 0".
Add a `processFramedCommit` wrapper on MlsGroupManager that mirrors
MlsGroup.processFramedCommit + the retention/persistence bookkeeping
from processCommit, and switch the four tests to feed framedCommitBytes
through it. MarmotInboundProcessor is unaffected (it already has the
decoded PublicMessage fields).
All 7 originally-failing Marmot tests now pass; the 4 unrelated
NostrClient network tests remain independently flaky.
https://claude.ai/code/session_01XQNAmwn1y87GAoK94QWgyn
Two bugs in MlsGroup were blocking 12 of 209 jvmTest cases (all RFC 9420
interop vectors already passed; only our higher-level lifecycle code
diverged from the spec).
Bug A — write/read asymmetry on member commits. processCommit requires
signature + confirmation_tag as separate parameters, but CommitResult
only exposed framedCommitBytes (the full PublicMessage envelope). Tests
called processCommit(commitBytes, ..., ByteArray(0)) and immediately
hit "FramedContentTBS signature missing on commit from leaf N".
Add MlsGroup.processFramedCommit(framedCommitBytes) that unpacks the
MlsMessage(PublicMessage(Commit)) envelope, verifies membership_tag for
member senders (RFC 9420 §6.2), and delegates to processCommit. Mirrors
the unwrap MarmotInboundProcessor already does in production. Updates
the 9 affected tests to use the new entry point.
Bug B — externalJoin used the wrong HPKE info bytes. The joiner
encrypted UpdatePath path-secrets against groupContext.toTlsBytes()
(pre-mutation), but receivers HPKE-Open against the post-mutation
context (epoch+1, new tree_hash) per RFC 9420 §7.6. Result:
AEADBadTagException on every external join.
Refactor externalJoin to follow the same staging pattern as commit():
stage public keys, applyUpdatePath, patch parent_hash, replace
placeholder leaf — then compute pathEncContextBytes from the post-
mutation tree, then HPKE-encrypt path-secrets with that context.
To make the framed pipeline available to external commits as well,
introduce ExternalJoinResult exposing both commitBytes and
framedCommitBytes (PublicMessage with sender = new_member_commit, no
membership_tag). processFramedCommit resolves NEW_MEMBER_COMMIT senders
to the receiver-side leaf index using the same first-blank-or-append
algorithm as RatchetTree.addLeaf, so wire decoding doesn't need to
carry the joiner's chosen index in the empty Sender field.
After: 209/209 MLS tests pass.
Two harness-side bugs that made a broken wn silently look like a working one:
1. whitenoise-skip-unprocessable-retry.patch's hunk header declared
`@@ -178,7 +178,23 @@` but the new side only has 22 lines. GNU patch
bails with "malformed patch at line 26" and leaves the target file
untouched. Fix the count to +178,22 so the patch actually applies.
2. setup.sh's patch-apply loop piped `patch` through `tee`, which
masked patch's exit code behind tee's. A miscounted hunk therefore
still touched the `.headless-patched-*` marker and the next run
skipped the "patch" step entirely, so the resulting wn ran the
stock 10-retry exponential backoff on every MlsMessageUnprocessable
— ~17min per event, which pegs the per-account serial event
processor and makes every later test timeout "just because".
Check patch's real exit status; fail preflight loudly instead.
Same class of bug was also hiding cargo build failures: the
`cargo build ... | tee` pipeline reported success even when rustup
couldn't fetch the toolchain manifest (503 from static.rust-lang.org)
or cargo couldn't hit the crates.io index (503 from fastly). setup.sh
then `info`-logged the expected binary paths and happily moved on —
until the daemon launch tripped over `nohup: No such file or directory`.
Now each cargo build runs in a 4-attempt retry loop that terminates
only when the binary actually exists on disk, and fails preflight
loudly if it never materialises. Matches the jitpack 503 retry
behaviour already in place for `:cli:installDist`.
https://claude.ai/code/session_018kSBco5VVfctkW6vAm7NzA
jitpack.io and dl.google.com both return 503 on a non-trivial fraction
of cold-cache fetches, and Gradle disables the whole repository for the
rest of the build the moment a single 503 lands — so one bad roll
aborts the entire harness before any test gets to run. Wrap the gradle
invocation in a 4-attempt retry loop; each attempt resumes from
Gradle's local cache so only the still-missing artifacts get re-fetched.
In practice the retry completes in seconds when jitpack is the only
flake, and the whole preflight still wall-clocks well under the first
attempt's runtime. Existing success path is unchanged.
https://claude.ai/code/session_018kSBco5VVfctkW6vAm7NzA
The `.result` wrapper fix landed in lib.sh's pollers and the headless tests
back in f08b010f, but the 8 inline `jq` reads in marmot-interop.sh were
missed — which silently returned empty against current `wn` and made every
UI-verification test look like "Amethyst didn't propagate X" when in fact
the underlying wn state was correct and the script just couldn't see it.
All 8 reads now peel `(.result // .)` the same way the lib.sh helpers do,
staying backwards-compatible with the pre-v0.2 flat shape:
* test 02, 04 member lists
* test 06 member-removed check
* test 07 group-name poll
* test 08 admin list (promote + demote)
* test 09 anchor message lookup
* test 11 leave-group member check
Also split the configure_relays sanity check into three relay-level
surfaces so a broken relay set points at the specific kind it can't
handle, instead of 10 later tests all timing out with the same "no
invite" symptom:
* kind:30443 (KeyPackage) — both sides publish + symmetric discover
* kind:10050 (Inbox advertise) — C reachable as giftwrap target
* kind:1059 (Gift wrap) — B->C welcome delivery
* kind:445 (MLS commit/msg) — real group-message round-trip
And drop the orphan whitenoise-mdk-plaintext-policy.patch: it lived in
`headless/patches/` but was never referenced by setup.sh's patch array,
and if it ever were applied it would flip mdk-core's MLS wire-format
from MIXED_CIPHERTEXT to MIXED_PLAINTEXT — a protocol-breaking
divergence from every stock whitenoise-rs / White Noise build. Removing
it eliminates the trap of a future contributor "enabling" it.
https://claude.ai/code/session_018kSBco5VVfctkW6vAm7NzA