The reverse-rename leg issued `wn_b groups rename` on Interop-02, but
Amethyst created Interop-02 — A is the admin, B is a plain member.
MIP-03's `enforceAuthorizedProposalSet` rejects any GroupContextExtensions
commit from a non-admin, so wn's rename was being dropped client-side,
the metadata commit never reached any relay, and Amethyst's UI (correctly
waiting for a kind:445 on Interop-02) never saw "Interop-02-reverse".
Split Test 07 into two separately-recorded legs so each uses a group
where the renamer is actually the admin:
07a — A (admin of Interop-02) renames to "Interop-02-renamed" via
the Amethyst UI; wn-side poll observes the new name.
07b — B (admin of Interop-03) renames to "Interop-03-renamed" via
`wn_b groups rename`; operator confirms Amethyst picked it up.
Each leg individually records pass/fail/skip so a missing GROUP_03
(Test 03 skipped) doesn't take out the whole test.
Test 06 was asking the operator to remove C from Interop-05 via the
Amethyst UI — but Interop-05 was created by B (wn), so B is the sole
admin and A is a plain member. MIP-03's authorization gate
(`enforceAuthorizedProposalSet` in `MlsGroup.kt:1842`) rejects any
Remove proposal from a non-admin, so the UI correctly fires "not an
admin" and the test fails against its own author's assumption — the
test was misaligned with the admin model, not the code.
Flip the flow: B (admin) issues the removal via `wn groups
remove-members <gid> <C_NPUB>`, and A / C observe the effect. That
still exercises the same intended invariants:
- B + A remain in the group, commit + epoch advance propagate.
- A's Amethyst UI shows C gone (visible confirm).
- A sends "after removing C" → B decrypts (expected).
- C cannot decrypt the new message (forward secrecy at the new
epoch — C's retained keys only open the pre-remove epoch).
The "Amethyst admin issues remove" path is already separately
covered by Test 02 (A creates the group → A is admin → A removes)
and Test 08 (promote A, then A issues an admin-only action). Test
06's focus is forward secrecy after remove, not who may initiate —
so having B drive it is the spec-correct shape.
The interop harness was forcing specific relays on the Amethyst account —
"configure Amethyst with these five relays, also add them to your DM
Inbox list, also to your Key Package Relays, then republish" — which
made the test artificial:
- It masked the real failure modes (wn publishing to an A-advertised
relay wn can't reach, A's 10050 pointing at auth-required relays,
etc.) because everyone was always on the same happy-path set.
- It mutated the operator's real Amethyst account config — those
five relays persist after the run.
- It short-circuited wn's discovery chain (kind:10002 → 10050 →
10051 resolution), so the one path most likely to hold subtle bugs
was never exercised under divergent configs.
Real-world interop is: both clients have their own independently-chosen
relay sets and use the advertised kind:10002/10050/10051 to find each
other. Make the harness match that:
- Keep the five-relay set as the wn B/C bootstrap (they still need
somewhere to publish their own identity). Drop the "configure
Amethyst to match" steps from `instruct_amethyst_setup`;
confirmation only now ("is A logged in as <npub>, and has A
published its four lists to any public relay?").
- Add `discover_a_relays` step between configure + the operator
prompt. Calls `wn keys check $A_NPUB` on both daemons to trigger
wn's targeted fetch for kinds [0, 10002, 10050, 10051] (the
only side-effect available to populate wn's user_relays cache).
Then reads the cache directly via sqlite3 against wn's DB and
prints what wn actually learned, grouped by relay_type. Warns
loudly if A's 10050 shares zero relays with wn's bootstrap — the
exact failure mode that caused the prior round of "Test 03
silently times out" reports.
- `--local-relays` path keeps the old behavior: offline/sandbox
mode, the harness owns the relay so forcing Amethyst onto it is
correct.
Scope is the harness only. No Amethyst / quartz / commons changes.
Test 03 (wn creates a group, invites Amethyst) was failing at the
operator-visible level — "I don't see Interop-03 on Amethyst" — with
no clear signal in the Amethyst logcat because no kind:1059 welcome
gift wrap ever arrived.
Root cause: when wn invites Amethyst it fetches A's kind:10050 inbox
list and publishes the welcome gift wrap there. Amethyst's built-in
DM inbox defaults advertise auth.nostr1.com + relay.0xchat.com, which
wn (connected only to the five harness public relays) cannot reach.
Unless the operator manually adds the harness relays to Amethyst's
DM Inbox list, the gift wrap lands on relays the reader isn't
subscribed to, and the invite is silently dropped.
Confirmed empirically by adding wss://nostr.bitcoiner.social (one of
the harness relays) to Amethyst's DM Inbox list and watching
Interop-03 appear on the next wn create.
Update `instruct_amethyst_setup` to make the DM Inbox step explicit
and call out the specific failure mode it prevents. Also add a nudge
to verify NIP-65 + kind:10050 were actually republished after the
relay list edits.
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 previous commit (6ad522b5) shielded the chatroom from the mistaken
"duplicate" drop but was treating the symptom. The real cause of the
"inner event already in cache" log — reported while Test 02's chat
stayed completely empty — is that `cache.justConsume(innerEvent, null,
wasVerified=false)` runs `event.verify()` on a wn-signed inner kind:9,
that verify fails (likely JSON-canonicalization skew or a rumor-style
empty signature), so `consumeRegularEvent` bails at line 633 before
ever setting `note.event`. LocalCache then always had the empty Note
registered by id but with no event content — subsequent arrivals re-hit
line 615 (`note.event != null` is still false, not the "duplicate"
path), retried verify, failed again, and the chatroom never got a
populated message to render.
The inner event has already been cryptographically authenticated by
MLS — `MarmotInboundProcessor.processPrivateMessage` rejects any inner
event whose `pubkey` doesn't match the MLS sender's credential
identity (MIP-03, line 430). Running Nostr's `event.verify()` again
is a redundant gate. Pass `wasVerified=true`: `consumeRegularEvent`
skips `justVerify` and calls `note.loadEvent(...)` on first arrival,
chatroom-add works immediately, and the test passes.
Also tightens the "already in cache" comment to reflect what that
branch actually means now (a legitimate hydrated-by-startup-restore
duplicate, not a silent verify failure masquerading as one).
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
The B->Amethyst kind:445 reply was decrypting fine — the harness's
new outbound-side diagnostics + the Amethyst logcat showed a clean
ApplicationMessage path right up to the chatroom-add step:
GroupEventHandler.add: kind:445 id=988221d0… groupId=ee27bc48…
GroupEventHandler.add: ApplicationMessage decrypted innerKind=9
innerId=ebf35373… author=170df8d1…
GroupEventHandler.add: inner event already in cache (duplicate)
`cache.justConsume` returned false because the inner event id was
already in LocalCache (a prior session persisted the same plaintext
via persistDecryptedMessage and the startup restore loop in
Account.kt:3034-3044 reloaded it into the cache before this kind:445
arrived). The live-receive branch in DecryptAndIndexProcessor was
gated on `isNew` and silently dropped the message — never calling
marmotGroupList.addMessage — so the chatroom never saw B's reply
even though decryption succeeded.
Mirror what the restore path does: always surface the inner note
in the chatroom. `MarmotGroupChatroom.addMessageSync` is itself
idempotent (dedupes by Note identity), so the call is safe even
when the message was already added through another path.
`persistDecryptedMessage` stays guarded on `isNew` because the
message store is append-only and a re-persist would grow the
on-disk log unboundedly across decrypt retries.
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
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.
bash 3.2 (stock on macOS) raises "unbound variable" on "${gids[*]}"
when the array is empty under `set -u`. The happy-path sanity check
still worked (the array was populated), but the shell error aborted
execution before the actual test cases ran on a fresh state/ directory.
Only expand when the array has at least one entry.
Broaden the default public relay set for the interactive harness so
kind:1059 gift wraps and kind:10050 inbox lists have more landing
spots that don't pre-filter Marmot traffic out. Both relays are
known to accept kinds outside the core "note / metadata / contacts"
trio that damus/primal tend to restrict.
The Amethyst-side setup instructions are rendered from the same list,
so operators who configure Amethyst per the harness prompt get the
same five URLs on the app side too.
wnd persists pending group invites across daemon restarts, so on any
rerun C (and B) already have unaccepted welcomes from previous runs
lying around. `wait_for_invite` was pulling `.[0]` out of the invite
list, which is the oldest pending — i.e. the stalest — and treating
it as the arrival the caller just triggered. In the sanity probe this
caused the kind:445 step to send to B's NEW group id while C was
accepting a DIFFERENT (old) group, producing a bogus "kind:445 failed
— relays may be dropping group messages" with no actual relay issue.
Add `snapshot_invites <B|C>` which returns the current gids as CSV,
and teach `wait_for_invite` to accept that CSV as an ignore list so
it walks past stale welcomes and only returns a freshly-arrived one.
Use the new snapshot→wait pattern at every call site: sanity check,
Test 02 (B), Test 04 (C), Test 05 (C), Test 08 (C), Test 12 (C).
Also log a gid-mismatch warning in the sanity check when B's created
group and C's accepted welcome disagree, as an extra guard against
the filter being bypassed.
Several public relays (damus.io in particular) silently drop kind:1059 /
10050 / 30443 from accounts they've never seen before — the sanity
check in the interactive harness reports exactly this failure mode
when the three-relay default set is in use, because wn's identities are
brand-new and have nothing on file.
Before the kind:30443 + 10050/1059/445 probes, publish a kind:0
profile event for each wn identity via `wn profile update --name
--about`. That marks both accounts as "known" on the relay and lets
the subsequent gift-wrap and KeyPackage publishes land. Runs inside
configure_relays (after relays are added, before the first sanity
probe) so the sequencing is: add relay -> advertise profile -> publish
KPs -> start exchanging welcomes.
Does not help when a relay outright refuses the kind (some relays
whitelist only kind:1 / 3 / 7); those still need --local-relays.
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.
When `wn_b messages send` fires but Amethyst never displays the reply,
the harness used to swallow the send output and only ask the operator a
blind yes/no — giving no signal about which side of the pipe failed.
Amethyst subscribes to kind:445 on the MLS GroupContext `relays`
extension (falling back to `Account.homeRelays`), so the two
actionable failure modes are:
- wn published to a relay Amethyst isn't subscribed to (group-relay
mismatch between the MLS extension and Amethyst's subscription)
- Amethyst received the event but `GroupEventHandler.add` dropped it
(no `h` tag, `isMember=false`, decrypt error)
Teach the harness to dump both sides automatically on the reply step:
- Capture wn's `messages send` stdout/stderr into the run log.
- `dump_outbound_diagnostics` prints wn's local view of the group
messages (did the send persist to wn's own DB?), the MLS
GroupContext relay list (what Amethyst should be subscribed to),
and live relay connection status.
- On operator-reported fail, prompt for an Amethyst-side logcat
grep targeted at the exact log points — MarmotGroupEventsEoseManager
filter updates, GroupEventHandler.add arrivals, and the membership
drop branch — naming the current group id so the operator can grep
for the right hex prefix.
`wn users show <npub>` only resolves identities the local daemon has
cached, so A's npub (the Amethyst account, which B's daemon has never
interacted with directly) falls through to the "return input unchanged"
branch. That leaks the raw npub into the Test 02 member-list assertion,
where it's compared against hex pubkeys pulled from `wn groups members`
and fails with "member list missing npub1...".
Add a pure-awk bech32 decoder and use it as the final fallback in
`npub_to_hex`, so unknown npubs still convert to the 32-byte hex form
the daemon emits. Also harden the JSON path to peel a `.result` wrapper
in case a future `wn --json users show` ships one.
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