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
Mirrors the Chess drawer entry pattern — `if (isDebug)` gates the
row. Audio rooms' Nostr surface (presence, hand-raise, chat) works,
but without the WebTransport audio backend the entry would bait
release-build users into a room they can't actually listen to.
One-line filter at the call site in DrawerContent.ListContent:
val feedsItems = if (isDebug) DrawerFeedsItems
else DrawerFeedsItems.filter { it != NavBarItem.AUDIO_ROOMS }
CatalogSection(..., feedsItems, ...)
Catalog entry + route + screen wiring all stay, so debug builds still
see it and the Nostr feature continues to be exercisable. When
`QuicWebTransportFactory.connect()` stops throwing NotImplemented the
two lines go away.
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
The branch already covers the Nostr-side of audio rooms (drawer
listing, 30312-event parsing, 10312 presence publishing, chat, hand-
raise). The audio-transport pieces (WebTransport + MoQ + Opus play-
back) are blocked on Phase 3b-2 (pure-Kotlin QUIC — see the plan in
`docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md`).
The problem: the in-progress AudioRoomConnectionViewModel was
auto-calling connectNestsListener() on stage enter, and the stub
`KwikWebTransportFactory` throws `NotImplemented`. That surfaced as a
persistent red "Audio failed: WebTransport NotImplemented" chip
every time a user opened any audio room — scary, confusing, and
misleading.
Hiding the broken UI until the transport lands:
- Deleted `AudioRoomConnectionViewModel.kt`. The
AudioRoomConnectionViewModel + ConnectionChip code is recoverable
from git (commit `64b33674`) when Phase 3b-2 makes the underlying
transport real.
- Removed the auto-connect LaunchedEffect + ConnectionChip render
from AudioRoomStage.
- Removed the mute button. Pressing mute when we're not producing a
mic stream would publish `["muted","0"|"1"]` on 10312 presence
with no corresponding audio signal — misleading to peers. The
builder-side support for the muted tag stays in
MeetingRoomPresenceEvent (Quartz) for future use; the UI will
re-add the button when audio capture actually runs.
- `publishPresence(..., muted = null)` so the muted tag is elided
from the broadcast event entirely while the feature is hidden.
- Dropped the unused strings (audio_room_mute, audio_room_unmute,
audio_room_conn_*) from strings.xml.
What still works on the branch (verified):
- Audio Rooms drawer entry lists kind 30312 rooms.
- Tap a room → live-activity channel screen with the audio-room
stage overlay showing title, summary, host+speaker avatars,
audience avatars.
- Kind 1311 chat (read + send) via the existing channel infra.
- Kind 10312 presence published on enter + every 30 s with the
correct `a`-tag and `["hand","1"|"0"]` reflecting local state.
- Hand-raise button toggles the `hand` tag — browser clients +
other NIP-53 peers can see the signal and hosts can promote.
What's hidden until Phase 3b-2 lands:
- WebTransport connection attempt, connection chip, mute button.
Re-enabling them is additive: resurrect AudioRoomConnectionViewModel
from git, paste three LaunchedEffect / DisposableEffect /
ConnectionChip blocks back into AudioRoomStage, restore the five
string resources, restore the mute button + the two-arg muted
presence call. Zero protocol impact on anything else.
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
Quartz:
- processWelcome now enforces hash(ratchet_tree) == GroupContext.tree_hash
and verifies the joiner-derived confirmation_tag matches GroupInfo's,
closing the gap where a tampered but-signed GroupInfo could diverge
the joiner's epoch secrets.
- externalJoin does the equivalent tree_hash check.
- Promoted constantTimeEquals to a file-level helper so the companion
object (processWelcome) can use it alongside instance methods.
Tests:
- New MlsGroupNegativeTest exercises every authenticity knob on inbound
commits: tampered confirmation_tag, bit-flipped signature, spoofed
senderLeafIndex, wrong wire_format, empty confirmation_tag, and
tampered/missing membership_tag — each must throw and leave the
receiving group's epoch unchanged (atomic-rollback regression cover).
- Fixed stale jvmAndroidTest references to the removed `selfRemove()`
helper — use `buildSelfRemoveProposalMessage()` (now Pair-returning).
Interop harness:
- Adds tests 14–16 as inverted-role scaffolding (wn drives, amy receives).
Currently recorded as SKIP with explicit notes:
14 / 15 block on filtered-direct-path UpdatePath support in
quartz's applyUpdatePath (RFC 9420 §7.7 path compression).
16 blocks on a createdAt-sorted KeyPackage fetch path — the current
fetchFirst-based fetcher is non-deterministic on relay order.
Preserves the test functions so they start running the moment those
gaps are closed.
Captures the gap between NIP-53's room discovery and what audio-capable
clients/servers must actually agree on to interop. NIP-53 defines the
30312 event but leaves the HTTP control plane + MoQ namespacing +
audio codec params entirely to individual implementations. With Nests
going generic-server, this is the moment to standardize.
What the draft covers:
1. HTTP control plane:
- Path convention: GET <service>/<d-tag>
- NIP-98 `Authorization: Nostr <base64>` header required
- JSON response shape (endpoint, token, transport, codec,
sample_rate, frame_duration_ms, moq_version)
- Canonical error-status map (401/403/404/410/503)
2. WebTransport + MoQ handshake requirements (Extended CONNECT,
required HTTP/3 settings, Bearer token passing).
3. MoQ track naming — vendor-neutral one-element namespace
`[<d-tag>]` with track-name = speaker-pubkey-hex. Explicit
rejection of the `["nests", <d-tag>]` prefix for new deployments.
4. Audio object format: raw Opus packets (no Ogg, no TOC), 48 kHz
mono, 20 ms default, mono PCM 16-bit decode target. Both
OBJECT_DATAGRAM and STREAM_HEADER_SUBGROUP accepted; listeners
MUST handle both.
5. Per-track access control: server MUST verify publishing pubkey is
a host/speaker in the current 30312 event (which is replaceable —
revocation cascade is spec'd).
6. Leave procedure (UNSUBSCRIBE, UNANNOUNCE, final 10312 presence,
WT_CLOSE_SESSION capsule).
7. Presence extension: the `["muted", "1"|"0"]` tag we already ship
in nestsClient's MeetingRoomPresenceEvent overload, promoted from
Amethyst-specific to NIP-defined.
8. Server + client requirements summaries.
9. Known divergences from current nostrnests/nests servers (two-
element `["nests", <d-tag>]` namespace + `/api/v1/nests/<d-tag>`
path) with a transition strategy via a `"nip_xx": true` flag in
room-info responses.
10. Security considerations (bearer-token handling, NIP-98 `u` tag
binding, audio-replay attack surface, server-impersonation VU
meter recommendation).
Deliberately out of scope: E2E-signed audio objects (future NIP),
federation between audio servers, room recording/transcription.
Intended workflow: share with the Nests team while they're designing
the generic server, land changes against their feedback, then open a
PR on nostr-protocol/nips.
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
Updates the Architecture section to reflect the decision to house the
pure-Kotlin QUIC + HTTP/3 + WebTransport code in a new top-level
Gradle module `:quic`, sibling to `:quartz`, `:commons`, `:nestsClient`.
Key changes:
- Module placement: new KMP module `:quic` with commonMain +
jvmAndroid + jvmMain + androidMain source sets (mirrors Quartz).
`:quic` takes `api(project(":quartz"))` so all crypto primitives
are in-scope without re-export.
- settings.gradle delta spelled out.
- Package layout shifts from
`nestsClient/src/jvmAndroid/...transport/quic/` to
`:quic/src/commonMain/com/vitorpamplona/quic/`, with UDP
socket-specific bits in `jvmAndroid/`.
- Varint.kt migration called out: moves from
`nestsClient/moq/Varint.kt` to `:quic` at
`com.vitorpamplona.quic.Varint`. Mechanical, one commit.
- Rename KwikWebTransportFactory stub → QuicWebTransportFactory
(real), living in `:quic` but implementing `:nestsClient`'s
existing `WebTransportFactory` interface. AudioRoomConnectionViewModel
changes one ctor call.
- Rationale section documenting why Option A beats putting it in
`:nestsClient` (single responsibility / reusability / security
boundary / test isolation / build graph / charter fit).
- Phase A now explicitly includes the module-creation +
Varint-migration step; test suite must stay green across the move.
Timeline unchanged (17-19 weeks). Dependency story unchanged
(no external libs; everything piggybacks on Quartz).
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
- Verify FramedContentTBS signature on inbound commits using the
sender's pre-commit leaf signature key (rejects forged commits
under a compromised exporter key).
- Verify membership_tag on inbound PublicMessage commits before
advancing state.
- Check LeafNode lifetime bounds (notBefore..notAfter) on UpdatePath.
- Require confirmation_tag to be non-empty.
- Thread wire_format into buildCommitFramedContentTbs so signature
verification reproduces the bytes the sender signed.
- Snapshot RatchetTree + groupContext + epochSecrets + initSecret +
secretTree + interimTranscriptHash + pendingProposals + sentKeys
at the top of processCommit, and restore on any throwable so a
verification failure mid-apply can't leave the group diverged.
Audited Quartz's crypto surface and found every primitive the QUIC
plan called out for BouncyCastle is already present in commonMain:
- `utils/ciphers/AESGCM.kt` — AES-GCM with AAD (QUIC-TLS AEAD)
- `nip44Encryption/crypto/ChaCha20Poly1305.kt` — pure-Kotlin AEAD
- `nip44Encryption/crypto/Hkdf.kt` + `utils/mac/MacInstance.kt` — HKDF
- `utils/sha256/Sha256.kt` — SHA-256
- `marmot/mls/crypto/X25519.kt` — X25519 ECDH (TLS 1.3 key exchange)
- `marmot/mls/crypto/Ed25519.kt` — Ed25519 signatures
- `utils/SecureRandom.kt`
Plan update highlights:
- "What we delegate" section rewritten to point at Quartz primitives.
- Phase B renamed from "TLS via BouncyCastle" to "TLS 1.3 client state
machine on Quartz primitives"; bumped from 2 to 3 weeks because we
write the state machine ourselves, but eliminates the entire
BC-adapter integration risk.
- "Dependencies to add" section zeroed out — nothing goes in
gradle/libs.versions.toml. The only new primitive is a thin
HKDF-Expand-Label helper on top of existing `MacInstance`, which
we can upstream to Quartz's `Hkdf` as a general `expand(prk,
info, length)`.
- Risk table rewritten: removed BC-specific risks, added
Quartz-specific ones (verify `X25519.dh` against RFC 7748 vector
on Phase B day-1).
- Cert chain signature verification uses JDK `Signature` for RSA +
ECDSA plus Quartz `Ed25519` for Ed25519 leaves.
Total timeline moves from 16-18 weeks to 17-19 weeks — same band,
but with zero external dependencies and zero Maven-resolution risk.
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
Captures the plan to unblock Phase 3b-2 (the WebTransport handshake)
by writing a Kotlin QUIC client rather than depending on a Java QUIC
library. Triggered by exhausting the off-the-shelf options:
- tech.kwik:* coords don't exist on Maven Central we can resolve.
- Netty incubator HTTP/3 needs an Android quiche-native that isn't
published.
- Cronet doesn't expose WebTransport.
- WebView JS bridge rejected by product.
The plan delegates TLS 1.3 + crypto primitives to BouncyCastle (bcprov
+ bcpkix already cached, bctls to add) and has us writing the QUIC
packet/frame/state-machine layer + HTTP/3 + WebTransport on top.
Realistic estimate: 16-18 weeks one developer full-time, or 5-6
months at a normal cadence, plus a security review before shipping.
Hard abandonment trigger documented: if the RFC 9001 Appendix A
Initial-packet test vectors don't bit-match by end of Phase D
(~6 weeks in), abandon and wait for an Android-compatible upstream.
The plan is committed as a doc rather than executed in this session
because each phase is multi-week and would block the rest of the
audio-rooms feature in the meantime.
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
13/13 passing; the System.err.println trace in
processPrivateMessage/applyCommit that we added to diagnose the
wire_format / admin / SelfRemove-proposal bugs is no longer needed.
Logging via the existing Log.d("MarmotDbg") calls is preserved.
https://claude.ai/code/session_1469d7f4-bb66-4ffa-a44d-1dfa4b526484
Test 07 second half exercises a rename from the wn side, but
GROUP_02 is created by amy, so amy is the sole admin. wn's
ensure_account_is_group_admin silently refuses B's rename and
never publishes anything. The test design assumed a symmetric
admin set; the MIP-01 admin check makes that explicit.
Add an `amy marmot group promote <B>` step between amy's rename
and B's rename so B is admin when it tries, matching the real
round-trip intent. Also sleep 3s to let wn apply the promote
commit before firing the rename.
https://claude.ai/code/session_1469d7f4-bb66-4ffa-a44d-1dfa4b526484
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
SelfRemove is standardized in draft-ietf-mls-extensions with
IANA-registered proposal type 0x000A. openmls and mdk encode it
that way on the wire. Quartz was writing 0xF001 (Marmot private-use
range), so mdk's strict tls_codec read 0x000A for a known proposal
type it didn't recognize and the decoder drifted past the variant
body, surfacing later as
Tls(DecodingError("Trying to decode Option<T> with 64 for option..."))
when it reached `Commit.path` with misaligned bytes.
Fixes test 11 (amy leave → B still sees A as member).
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
Phase 3b-2 attempt. The honest version: I do not have network access
to verify Kwik's current Maven coordinates or test against a live
nests server, so committing speculative QUIC + Extended CONNECT code
risks breaking the build for everyone else without giving us
audible-audio-on-device confidence in return.
What I did instead: expanded the docstring on KwikWebTransportFactory
into a full integration playbook that the next person picking this
up can execute directly. It covers:
- Maven coordinates to verify (`tech.kwik:kwik-core` + `tech.kwik:flupke`,
with `net.luminis.quic:kwik` as the legacy fallback group).
- The exact `gradle/libs.versions.toml` + `nestsClient/build.gradle.kts`
edits to drop in once coords are confirmed.
- Step-by-step handshake sequence with the right HTTP/3 setting IDs:
* SETTINGS_ENABLE_CONNECT_PROTOCOL=1 (RFC 8441, 0x08)
* SETTINGS_ENABLE_WEBTRANSPORT=1 (WT-H3 draft, 0x2b603742)
* SETTINGS_H3_DATAGRAM=1 (RFC 9297, 0x33)
- Extended CONNECT pseudo-header set, including the legacy
`sec-webtransport-http3-draft02 = 1` for older server compat.
- WebTransport stream-type prefix bytes (0x41 for client bidi, 0x54
for client uni) + WT capsule type for graceful close (0x2843).
- Test strategy: validate against local nests-rs first, then the
production `nostrnests.com`.
- Open questions (Flupke Extended CONNECT maturity, draft churn,
dev-only self-signed-cert support, Android API surface).
Behavior unchanged: connect() still throws WebTransportException
with Kind.NotImplemented and a message pointing at this docstring.
When the real implementation lands, no upstream caller needs to
change — the AudioRoomConnectionViewModel from Phase 3d-3 will
auto-light up the connection chip from "Failed: NotImplemented" to
"Connected" and start producing audio through the Phase 3d-1
pipeline that's already wired.
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
Phase 4 (codec + audio capture slice). Adds the publisher-side MoQ
control messages and the Opus encode + microphone capture pieces a
speaker needs. The host-grants-speaker UI flow is deferred — that's
multi-screen UX that should be designed before being implemented.
commonMain (nestsclient.moq):
- 5 new MoqMessageType entries with codec round-trip + tests:
* Announce (0x06): publisher offers a track namespace.
* AnnounceOk (0x07): subscriber acknowledges.
* AnnounceError (0x08): subscriber rejects with error code +
reason phrase.
* Unannounce (0x09): publisher withdraws a previously-announced
namespace.
* SubscribeDone (0x0B): publisher tells the subscriber no more
objects are coming for this subscription, with stream count and
reason.
commonMain (nestsclient.audio):
- New `OpusEncoder` interface — symmetric to `OpusDecoder`, one
instance per outgoing track since Opus state is per-stream.
- New `AudioCapture` interface — `start()`, `readFrame()` returns a
PCM frame or null when stopped, `stop()` releases the mic.
androidMain (nestsclient.audio):
- `MediaCodecOpusEncoder` — wraps `MediaCodec("audio/opus")` encoder
variant (API 29+). 48 kHz mono in, 32 kbit/s VBR Opus out, 20 ms
frames. Drains output queue per encode call.
- `AudioRecordCapture` — wraps `AudioRecord` from
`MediaRecorder.AudioSource.VOICE_COMMUNICATION` so the platform's
echo-cancellation + noise-suppression filters apply when available.
Reads exactly one PCM frame per readFrame() call, retries on
underrun, throws AudioException(DeviceUnavailable) on permission/
resource failures.
commonTest:
- `AnnounceCodecTest` — 7 cases covering each message round-trip,
concatenated decode in sequence, and a guard against accidentally
reordering MoqMessageType enum codes.
Permission already declared:
- `RECORD_AUDIO` is already in amethyst/AndroidManifest.xml — no
manifest change needed.
What this does NOT include:
- A publisher loop class (analogous to AudioRoomPlayer for publish
direction) — can be added when the speak-button wiring lands.
- The host-grants-speaker UI — needs design input.
- STREAM_HEADER_SUBGROUP for stream-based object delivery — datagrams
cover the listener happy-path; streams add reliability that nests
may or may not require.
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
PrivateMessage commits apply their Commit body inline inside
`MlsGroup.decrypt`, so the epoch advance + extension replace
happen in `MlsGroupManager.decrypt` — not in `processCommit`.
The old `decrypt` path never called `persistGroup`, so amy's
in-memory promotion (e.g. test 08 "B promotes A") looked correct
for the current CLI invocation but the NEXT `amy marmot …` command
reloaded the pre-commit extensions from disk and refused the
follow-up rename with "MIP-01: only admins may update group
extensions".
Detect epoch advance + COMMIT result inside `decrypt`, then push
the pre-commit exporter into the retained-epoch window and persist
the group so CLI reloads see the post-commit state.
https://claude.ai/code/session_1469d7f4-bb66-4ffa-a44d-1dfa4b526484
Phase 3d-3 of the Clubhouse/nests integration. Wires the
nestsClient.connectNestsListener() facade into AudioRoomStage via a
dedicated Android ViewModel, and surfaces the listener's StateFlow
through a small assist chip so the user sees connection progress and
failure modes.
amethyst/audiorooms/room:
- New `AudioRoomConnectionViewModel` (Android `ViewModel`):
* Owns one `NestsListener` + one `AudioRoomPlayer` per host/speaker.
* `connect(event, signer)` resolves the room HTTP-side, opens
WebTransport, runs MoQ SETUP, then subscribes to every host +
speaker pubkey from the 30312 event and wires their Opus stream
through `MediaCodecOpusDecoder` -> `AudioTrackPlayer`.
* `disconnect()` is idempotent, also called from `onCleared()`.
* Mirrors the underlying NestsListener.state into its own
StateFlow so callers observe one source.
* Audience members are skipped — they don't publish audio.
- `AudioRoomStage` now mounts the ViewModel keyed by the room
address, auto-calls `connect()` in a LaunchedEffect, and
disconnects in a DisposableEffect.
- New `ConnectionChip` composable renders the listener state with
retry-on-tap for Idle / Failed / Closed, and color-codes Connected
(primary) and Failed (error) states.
amethyst:
- Added `:nestsClient` to the project's dependencies.
res:
- New strings for the five connection-chip states.
Behavior today: on opening an audio-room, the chip will report
`Connecting (OpeningTransport)` then immediately
`Failed: WebTransport NotImplemented: ...` because the Kwik handshake
in `KwikWebTransportFactory` is the next phase. Tapping the chip
retries — same outcome until 3b-2 ships. Everything else (presence,
chat, hand-raise, mute) is unaffected.
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
RFC 9420 §8.2 says ConfirmedTranscriptHashInput carries the
wire_format of the AuthenticatedContent being committed, not a
hard-coded value. openmls/mdk pass mls_content.wire_format()
through, so B's PrivateMessage commits use wire_format=2 in their
transcript-hash input. Quartz always wrote wire_format=1
(PublicMessage), so amy's receiver-side new_confirmed_transcript_hash
diverged from B's — and every tag derived from it
(confirmation_key, confirmation_tag, epoch secrets) diverged too,
surfacing as "Confirmation tag verification failed" once my
error-surfacing fix stopped hiding it.
Plumb the real wire format through processCommit and its decrypt()
dispatch so PrivateMessage commits from B are hashed as
PrivateMessage on amy's side.
https://claude.ai/code/session_1469d7f4-bb66-4ffa-a44d-1dfa4b526484
Phase 3d-2 of the Clubhouse/nests integration. Ties HTTP auth (3a) +
WebTransport (3b-1 stub) + MoQ session (3c) under a single
`connectNestsListener()` entry point with an observable state
machine, so audio-room callers see one resource and one StateFlow
instead of three layered handshakes. Pure commonMain — fully tested
against fakes; no network or Android needed.
commonMain (nestsclient):
- `NestsListener` interface — `state: StateFlow<NestsListenerState>`,
`subscribeSpeaker(pubkeyHex)`, `close()`. nests namespaces each
speaker's track as `["nests", <roomId>]` with the speaker's pubkey
hex as track name; subscribeSpeaker fills that in.
- `NestsListenerState` sealed class:
* Idle
* Connecting(step = ResolvingRoom | OpeningTransport | MoqHandshake)
* Connected(roomInfo, negotiatedMoqVersion)
* Failed(reason, cause?)
* Closed
- `DefaultNestsListener` — straight delegation to MoqSession once
connected.
- `connectNestsListener(httpClient, transport, scope, serviceBase,
roomId, signer, supportedMoqVersions)` — walks the three handshake
steps. Each failure short-circuits to Failed with a clear reason
string and the underlying cause attached; transport is torn down on
partial-handshake failure.
- `parseEndpoint(url)` — hand-rolled URL splitter so commonMain stays
free of a URL-library dependency. Handles default-port stripping,
rejects userinfo, preserves query strings.
commonTest:
- `NestsConnectTest` (5 cases, all using fakes):
* Happy path: walks resolveRoom -> transport.connect -> MoQ SETUP,
asserts state lands on Connected with the right roomInfo +
negotiated version, and verifies the transport saw the parsed
authority/path/bearer.
* resolveRoom failure short-circuits without touching transport.
* Transport handshake failure short-circuits with the right kind in
the reason.
* Malformed endpoint URL short-circuits.
* parseEndpoint covers default-port stripping, explicit port,
pathless URL, query string preservation.
This completes the pure-Kotlin integration. The only seam left
between `connectNestsListener()` and audible audio is the Kwik-backed
WebTransportFactory implementation (Phase 3b-2). When that lands, an
Amethyst AudioRoomViewModel can wire the existing AudioRoomStage to:
state = MutableStateFlow(NestsListenerState.Idle)
listener = connectNestsListener(...)
for each speaker p: AudioRoomPlayer(MediaCodecOpusDecoder(),
AudioTrackPlayer(), scope).play(listener.subscribeSpeaker(p).objects)
…and audio plays.
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
Phase 3d-1 of the Clubhouse/nests integration. Adds the listener-side
audio pipeline that turns a SubscribeHandle's Flow<MoqObject> into
audible PCM through Android's MediaCodec + AudioTrack. Encoder /
AudioRecord (speaker publishing) lands in Phase 4.
commonMain (nestsclient.audio):
- `AudioFormat` constants — 48 kHz mono signed-16-bit, 20 ms frames
(960 samples), matching the nests Opus profile.
- `OpusDecoder` interface — stateful per-track decoder.
- `AudioPlayer` interface — start/enqueue/stop, suspend on backpressure.
- `AudioRoomPlayer` — wires a Flow<MoqObject> through OpusDecoder into
AudioPlayer. Decoder errors are surfaced via an `onError` callback
but don't tear down the loop (one bad packet shouldn't kill the
room); player errors are fatal. play()/stop() are single-shot per
instance, with stop() idempotent and double-release-safe.
- `AudioException` with three canonical kinds (DecoderError,
DeviceUnavailable, PlaybackFailed).
androidMain:
- `MediaCodecOpusDecoder` — wraps `MediaCodec("audio/opus")` with the
RFC 7845 §5.1 Opus identification header in csd-0 and zeroed
pre-skip / seek-pre-roll in csd-1 / csd-2. Drains the output queue
per-packet, handles INFO_OUTPUT_FORMAT_CHANGED gracefully.
- `AudioTrackPlayer` — wraps `AudioTrack` in MODE_STREAM with USAGE_
VOICE_COMMUNICATION / CONTENT_TYPE_SPEECH so the OS treats audio-
room playback like a phone call (call-volume rocker, ducks
notifications). Buffer = 4× minimum so the producer can fall behind
~80 ms (roughly the WebTransport datagram jitter on mobile).
Tests (commonTest, fake-based):
- `AudioRoomPlayerTest` — 7 cases covering happy-path decode +
enqueue, decoder failure with continued loop, stop idempotency,
double-play rejection, play-after-stop rejection, empty-PCM
skip-enqueue, and live channel-backed flow.
The real MediaCodec / AudioTrack code is thin glue against Android
APIs and is validated manually on device — no Robolectric here, the
seams are the Fake* doubles.
Next: Phase 3d-2 wires NestsClient end-to-end (HTTP auth → MoqSession
listener subscribe → AudioRoomPlayer) plus the Amethyst-side
AudioRoomViewModel. After that the only remaining pure-MoQ work is
the Kwik handshake (Phase 3b-2).
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
`MlsGroupManager.decrypt` used to swallow the current-epoch exception
via `decryptOrNull`, try retained epochs, then retry `group.decrypt`.
After our inline-processCommit change this means a mid-commit throw
leaves the group half-advanced, and the retry then reports a
misleading "Message epoch N doesn't match current epoch N+1" —
masking the real cause (unable-to-decrypt path, path-mismatch, etc.).
Swap the order: call `group.decrypt` directly first, capture any
exception, fall through to retained epochs, and re-raise the original
exception if every epoch fails. Retains same semantics for messages
that DO decrypt on the first try.
https://claude.ai/code/session_1469d7f4-bb66-4ffa-a44d-1dfa4b526484
Phase 3c-3 of the Clubhouse/nests integration. Promotes MoqSession
from a one-shot SETUP runner to a real concurrent session: the
control-stream and datagram pumps run on a caller-supplied scope
after the handshake, and a public subscribe()/unsubscribe() API
delivers per-track OBJECT_DATAGRAMs as a Flow<MoqObject>.
commonMain (nestsclient.moq):
- MoqSession now takes a `pumpScope: CoroutineScope` so callers can
bind the pumps to their lifecycle (test backgroundScope, viewmodel
scope, etc.).
- After setup() succeeds, two background coroutines start:
* Control-stream pump — buffers chunks across multiple writes,
decodes complete frames, dispatches SUBSCRIBE_OK / SUBSCRIBE_ERROR
to the matching CompletableDeferred, drops malformed frames
without killing the session.
* Datagram pump — decodes OBJECT_DATAGRAMs and routes each to the
matching per-track sink keyed by track_alias.
- `subscribe(namespace, trackName, priority, filter)` sends SUBSCRIBE,
awaits SUBSCRIBE_OK (throws MoqProtocolException on SUBSCRIBE_ERROR
with the publisher's reason), and returns a SubscribeHandle whose
`objects` flow emits inbound OBJECT_DATAGRAMs.
- `unsubscribe(subscribeId)` is idempotent; second call is a no-op.
close() cancels pumps + tears down all in-flight subscribes cleanly.
- Per-track sink uses a bounded Channel with DROP_OLDEST overflow
rather than MutableSharedFlow, so objects buffered between
subscribe() returning and the consumer attaching are preserved
(SharedFlow with replay=0 silently drops pre-subscription emissions).
transport (FakeWebTransport):
- Switched from `consumeAsFlow()` to `receiveAsFlow()` so a `.first()`
during setup followed by a long-running pump `.collect{}` works on
the same channel without the first call closing it.
Tests:
- Updated existing setup tests for the new `pumpScope` parameter.
- New `subscribe_completes_on_subscribe_ok_then_delivers_datagrams_in_order`
end-to-end: client subscribes, raw server peer (no MoqSession to
avoid pump-vs-test contention) replies SUBSCRIBE_OK + 3 datagrams,
client's flow yields all three in order with correct payloads.
- `subscribe_throws_MoqProtocolException_when_publisher_replies_with_subscribe_error`.
- `datagrams_for_unknown_track_alias_are_dropped_silently` ensures
the pump doesn't crash on orphan datagrams and the session stays
closeable.
This completes the pure-Kotlin MoQ work. Remaining 3.x phases all
touch real audio (MediaCodec Opus, AudioTrack, AudioRecord) or the
real WebTransport handshake (Phase 3b-2 with Kwik).
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
Tracing which path B→A commits take (processPrivateMessage vs
applyCommit) and why past-epoch PrivateMessage events surface
as Failure instead of Duplicate in tests 07/08/11/12. Will be
reverted once diagnosis is complete.
https://claude.ai/code/session_1469d7f4-bb66-4ffa-a44d-1dfa4b526484
Extend the past-epoch dedup check to PrivateMessage commits too.
Previously only the PublicMessage branch returned
`GroupEventResult.Duplicate` for a stale commit echo; the PrivateMessage
branch called `groupManager.decrypt()` directly, which consumed a
generation on the sender's ratchet and then failed with
`Message epoch X doesn't match current epoch Y` — polluting the harness
log and (worse) burning the real commit's generation slot.
Peek the epoch from the parsed PrivateMessage before touching the
secret tree: past-epoch → Duplicate, future-epoch → Error, same-epoch
falls through to the existing decrypt-and-dispatch path.
https://claude.ai/code/session_016kAxdp6ubB5CnF9URhCEzP
MDK-core (openmls) defaults group configuration to
`MIXED_CIPHERTEXT_WIRE_FORMAT_POLICY` — outgoing commits / proposals
ship as PrivateMessage. Quartz only handled PrivateMessage for
ContentType.APPLICATION; any handshake message came in via the
application ratchet, consumed the first application generation on
whatever sender sent an app message next, and then died with
`Generation 0 already consumed` the moment the receiver saw the real
PrivateMessage commit. That's why every A-side processing of B's
rename / promote / remove / leave commit timed out.
Fix:
1. SecretTree grows a parallel `handshakeKeyNonceForGeneration`
path, using the per-sender `handshakeSecret` / `handshakeGeneration`
the struct already tracked — with its own skipped-keys cache and
replay-detection set so handshake and application generations
never clash.
2. `MlsGroup.decrypt()` dispatches on the PrivateMessage's
`content_type` BEFORE consuming any ratchet — COMMIT and PROPOSAL
take the handshake path; APPLICATION stays on the existing
application path.
3. COMMIT bodies decode as `Commit || signature<V> || confirmation_tag<V>
|| padding` (RFC 9420 §6.3.1) and route into `processCommit` inline,
so the caller just sees the epoch advance — no new public API.
PROPOSAL still rejects (standalone proposals aren't used by
Marmot flows yet) with a clear message instead of a cryptic
generation-consumed error.
https://claude.ai/code/session_016kAxdp6ubB5CnF9URhCEzP
The interop harness prints `[cli] ingest <kind>/<id> via <relay> -> Failure`
for any commit/proposal/app message that quartz can't process. Without
the error string it's impossible to tell `confirmation_tag mismatch` from
`parent_hash invalid` from `commit references unknown proposal` without
reading the Kotlin stack. Append `result.message` for Failure outcomes so
the harness log is self-contained when diagnosing quartz→quartz errors
(the openmls side already has `{e:?}` via the mdk-core patch).
https://claude.ai/code/session_016kAxdp6ubB5CnF9URhCEzP
For a GroupContextExtensions commit, openmls signs the
`FramedContentTBS` over `group.public_group.group_context()` — the
UNMUTATED context, before the `diff` applies the GCE proposal. The
post-proposal extensions only make it into the HPKE path-encryption
context and the subsequent key schedule, not into the TBS / signature /
membership_tag.
Quartz's `applyProposal` handler for `GroupContextExtensions` mutates
`groupContext.extensions` inline — by the time `commit()` captured
`preCommitContextBytes`, the extensions were already updated. The
signature and membership_tag were then minted over the post-GCE context,
and openmls's `verify_membership` recomputed the MAC over the pre-GCE
context and reported `ValidationError(InvalidMembershipTag)` on every
cross-impl rename / promote / demote / leave.
Fix: snapshot `groupContext.extensions` before any proposal is applied,
and rebuild `preCommitContextBytes` with those original extensions. The
path-encryption context and post-commit key schedule still use the
updated extensions, matching openmls.
https://claude.ai/code/session_016kAxdp6ubB5CnF9URhCEzP
RFC 9420 §7.8: the ratchet tree has no trailing blank leaves — when a
Remove blanks the rightmost leaf, everyone MUST shrink `leaf_count` and
drop the (now-orphaned) parent nodes so future `direct_path`,
`resolution`, and `treeHash` computations see the new shape.
Quartz was blanking the leaf and its direct path but leaving
`_leafCount` untouched. Openmls / mdk shrink, so after
e.g. `amy removes C (leaf 2)` quartz still had `leafCount = 3` while
B's openmls had `leafCount = 2`. Amy's `direct_path` from leaf 0 then
had two entries (to the 3-leaf-tree root), but B's tree layout expected
one — and openmls rejected the commit with
`UpdatePathError(PathLengthMismatch)`.
Fix: after blanking the leaf + direct path, walk `leafCount` down past
any trailing blanks and truncate the `nodes` list to `nodeCount(leafCount)`.
https://claude.ai/code/session_016kAxdp6ubB5CnF9URhCEzP
Phase 3c-2 of the Clubhouse/nests integration. Extends the MoQ
control-plane codec with the subscribe lifecycle messages (SUBSCRIBE,
SUBSCRIBE_OK, SUBSCRIBE_ERROR, UNSUBSCRIBE) and adds the
OBJECT_DATAGRAM wire format the listener uses to receive low-latency
Opus audio. Pure codec layer — no session-pump or network changes;
the subscribe/object delivery Flow arrives in Phase 3c-3.
commonMain (nestsclient.moq):
- New message types in `MoqMessageType`: Subscribe (0x03),
SubscribeOk (0x04), SubscribeError (0x05), Unsubscribe (0x0A).
- New data classes in MoqMessage.kt:
* `TrackNamespace` — tuple of byte strings, with `of(vararg String)`
helper for the common UTF-8 case.
* `SubscribeFilter` enum. Phase 3c-2 supports LatestGroup /
LatestObject; AbsoluteStart/AbsoluteRange throw at construction
(they need extra wire fields we'll add when nests needs them).
* `Subscribe`, `SubscribeOk`, `SubscribeError`, `Unsubscribe` data
classes. `SubscribeOk` validates contentExists + largest-id
coupling at construction.
- New codecs in `MoqCodec` (namespace tuple + all four messages).
Unknown filter codes on the wire are rejected.
- `MoqObject` data class + `MoqObjectDatagram` encode/decode for the
OBJECT_DATAGRAM wire format (track_alias, group_id, object_id,
publisher_priority, status, payload).
Tests:
- `SubscribeCodecTest` — round-trips for each message with multi-
segment namespaces + parameters, SUBSCRIBE_OK with and without
contentExists, concatenated decode in sequence, unknown-filter
rejection, construction-time validation.
- `MoqObjectDatagramTest` — Opus-sized payload round-trip, zero-
length payload with OBJECT_DOES_NOT_EXIST status, 8-byte-varint
boundary coverage, truncated datagram rejection, out-of-range
priority rejection.
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
RFC 9420 §9.2: `commit_secret = DeriveSecret(path_secret_at_root, "path")`.
Openmls / mdk implement this exactly — after deriving the ratchet-tree's
own path_secrets up to (and including) the root, they advance one more
step and use THAT as the commit_secret contribution to the new epoch's
key schedule.
Quartz was using the root's own path_secret as commit_secret on both
the encryption side (MlsGroup.commit) and the decryption side
(MlsGroup.processCommit), plus in externalJoin's commit construction.
Internally consistent, so quartz↔quartz worked; but every cross-impl
commit (quartz→openmls or openmls→quartz) derived a different
epoch_secret, cascaded into a different confirmation_key, and failed at
`InvalidCommit(ConfirmationTagMismatch)` — the blocker behind every
test that actually needed an openmls peer to accept a quartz-produced
commit or vice-versa once the UpdatePath-layer bugs were out of the way.
Fix: add one `DeriveSecret(pathSecrets.last().pathSecret, "path")` step
on the sender side, and one extra `DeriveSecret(..., "path")` past the
root on the receiver side, mirroring openmls's `ParentNode::derive_path`
loop post-condition.
https://claude.ai/code/session_016kAxdp6ubB5CnF9URhCEzP
Phase 3c-1 of the Clubhouse/nests integration. Lands a MoQ-transport
control-plane codec (draft-ietf-moq-transport) and a session wrapper
that runs the SETUP handshake over any WebTransportSession. Purely
commonMain + fully tested end-to-end against FakeWebTransport — no
network or Kwik dependency required.
commonMain (nestsclient.moq):
- `Varint` — QUIC variable-length integer codec per RFC 9000 §16.
Encode/decode/size, with typed truncation handling (decode returns
null so callers can buffer more and retry).
- `MoqWriter` / `MoqReader` — append-only byte writer + bounds-checked
reader used by the message codec.
- `MoqCodecException` — typed error for malformed frames.
- `MoqMessage` sealed class + `MoqMessageType` enum + `SetupParameter`.
Phase 3c-1 covers just `ClientSetup` / `ServerSetup` (message types
0x40 / 0x41).
- `MoqCodec.encode/decode` — wraps payload with `type (varint) +
length (varint)`. Rejects unknown types and trailing bytes inside a
declared payload window.
- `MoqSession.client/server` — attaches to a WebTransportSession and
runs the CLIENT_SETUP / SERVER_SETUP handshake with version
negotiation + configurable timeout.
- `MoqVersion` constants for draft-11 and draft-17.
Tests:
- `VarintTest` — all four RFC 9000 §A.1 sample vectors (1/2/4/8 byte),
boundary round-trips, negative/overflow rejection, short-buffer
returns null, bytesConsumed accuracy.
- `MoqCodecTest` — CLIENT_SETUP / SERVER_SETUP round-trip (empty and
multi-param), multi-version negotiation, exact wire layout for
ServerSetup(1L), truncated-frame returns null, concatenated frames
decode with offset, unknown type rejected, trailing bytes rejected,
zero-length parameter values allowed.
- `MoqSessionTest` — full SETUP exchange over FakeWebTransport (both
sides happy path, client falling back to older version, server
rejecting when no version overlap + client timing out cleanly).
SUBSCRIBE / ANNOUNCE / OBJECT_STREAM / OBJECT_DATAGRAM land in
Phase 3c-2 on top of the same MoqSession.
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
RFC 9420 §8.2: the confirmed_transcript_hash for a Commit is taken over
`wire_format || FramedContent || signature` — where `signature` is the
real `FramedContentAuthData.signature`, not an empty placeholder.
Quartz was hashing over a zero-length signature, so the committer and
every receiver baked DIFFERENT `confirmed_transcript_hash` bytes into
their new GroupContext. The committer's confirmation_tag — MAC'd over
that value — never matched the receiver's recomputation, and openmls
rejected the commit with `InvalidCommit(ConfirmationTagMismatch)`.
Fix restructures the commit flow so the FramedContentTBS is built and
signed BEFORE the transcript hash update:
1. extract `buildCommitFramedContentTbs()` as a shared helper
2. in `MlsGroup.commit()`, sign the TBS immediately after building
the commit, then pass that signature to
`buildConfirmedTranscriptHashInput(..., signature)`
3. thread the signature through `framePublicMessageCommit` as a
parameter (callers already have it) instead of re-signing inside
`processCommit` also takes the signature and forwards it to
`buildConfirmedTranscriptHashInput`; `MarmotInboundProcessor.applyCommit`
pulls `pubMsg.signature` out of the `PublicMessage` envelope and passes
it through `MlsGroupManager.processCommit`.
Also switches `RatchetTree.derivePathSecrets` from a custom
`expandWithLabel(nodeSecret, "hpke", ...)` derivation to HPKE's standard
`DeriveKeyPair(node_secret)` (RFC 9180 §7.1.3). Without this, the
receiver derives parent-node public keys that don't match the ones
quartz baked into `UpdatePathNode.encryption_key`, which manifested as
`UpdatePathError(PathMismatch)` once the HPKE context fix landed.
https://claude.ai/code/session_016kAxdp6ubB5CnF9URhCEzP
Phase 3b-1 of the Clubhouse/nests integration. Lands the
[WebTransportSession] abstraction the MoQ layer (Phase 3c) will code
against, so MoQ framing + tests can develop in parallel with the real
Kwik-based transport integration (deferred to Phase 3b-2).
commonMain:
- `WebTransportSession` — bidi/uni stream access, datagrams, close.
- `WebTransportBidiStream` / `WebTransportReadStream` /
`WebTransportWriteStream` — minimal read/write surface.
- `WebTransportFactory.connect(authority, path, bearerToken)` for
opening sessions.
- `WebTransportException(kind, ...)` with four canonical failure modes
(HandshakeFailed, ConnectRejected, PeerClosed, NotImplemented) so UI
code doesn't need to know about library-specific exceptions.
- `FakeWebTransport.pair()` — in-memory, fully-wired client/server
pair for unit-testing MoQ framing without touching a real QUIC stack.
jvmAndroid:
- `KwikWebTransportFactory` stub that throws
`WebTransportException(NotImplemented)` at `connect()`. Doc spells
out the handshake sequence (QUIC dial → H3 SETTINGS →
`:method=CONNECT :protocol=webtransport` Extended CONNECT → 2xx) so
Phase 3b-2 can drop the real implementation in without touching
callers.
Tests:
- `FakeWebTransportTest` — datagram round-trip both directions,
bidi-stream write visible on peer side, close() flips isOpen.
- `WebTransportExceptionTest` — kind + message + cause preserved.
- `KwikWebTransportFactoryTest` — `connect()` currently fails with the
NotImplemented sentinel, so Phase 3b-2 can replace this test when
the real handshake lands.
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
RFC 9420 §7.6 / openmls `compute_path`: after applying the Commit's
proposals and the committer's UpdatePath locally, the committer must
bump the epoch and recompute tree_hash, THEN serialize the GroupContext
(with the still-pre-commit confirmed_transcript_hash) and use those
bytes as the HPKE info for encrypting every path secret. Receivers
perform the exact same recomputation on their copy of the tree before
decrypting, so the AEAD keys line up.
Quartz was using the fully pre-commit GroupContext on both sides. Self-
consistent (quartz→quartz passed), but openmls/mdk re-derives the AEAD
key from the post-mutation context and the tag check fails, which
surfaced as `InvalidCommit(UpdatePathError(UnableToDecrypt))` on every
commit-produced-by-quartz that an openmls peer had to process (add,
rename, remove, leave, promote, catchup).
Fix restructures `MlsGroup.commit()` into stage→apply→encrypt:
1. derive path-secret keypairs, stage UpdatePath entries with public
keys only
2. apply the staged path, patch parent_hashes, swap in the new leaf
3. serialize an intermediate GroupContext (new epoch + new tree_hash,
old confirmed_transcript_hash) and HPKE-encrypt each copath
resolution under that info
`processCommit()` mirrors the change on the decryption side, and also
splits the proposal loop so Add proposals apply after non-Adds (matching
the committer's order and unblocking the new-leaf-exclusion filter for
the decryption resolution lookup).
https://claude.ai/code/session_016kAxdp6ubB5CnF9URhCEzP
Phase 3a of the Clubhouse/nests integration. Adds a new KMP module
`nestsClient` (Android + JVM targets) that owns the HTTP control plane
for talking to a nests audio-room backend. No transport/audio yet —
that arrives in 3b with WebTransport + MoQ.
Surface:
- `NestsAuth.header(signer, url, method)` signs a kind 27235 event via
Quartz's existing HTTPAuthorizationEvent and returns a ready-to-use
`Authorization: Nostr <base64>` header value.
- `NestsRoomInfo` data class + tolerant JSON parser (ignores unknown
fields so newer nests server revisions don't break older clients).
- `NestsClient.resolveRoom(serviceBase, roomId, signer)` calls
`<serviceBase>/<roomId>` with the signed NIP-98 header and returns
the MoQ endpoint + token the audio layer will need.
- `OkHttpNestsClient` (jvmAndroid) is the default implementation
shared between Android and desktop JVM.
Tests:
- `NestsRoomInfoTest` (commonTest) covers full/minimal payloads,
unknown-field tolerance, missing-endpoint rejection, and URL
construction edge cases.
- `NestsAuthTest` (jvmTest) signs a real 27235 event, decodes the
base64 back through Quartz's JacksonMapper, and asserts the
signature verifies and the url+method tags bind correctly.
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
RFC 9420 §12.4.1: when a Commit contains Add proposals, the committer
MUST exclude the new leaves from the copath-resolution used for path-
secret encryption. Joiners get the group state via the Welcome at epoch
N+1; they neither need nor expect a path secret for epoch N.
Quartz was including them. openmls (via mdk-core) then runs its own
resolution-minus-exclusion-list when picking which ciphertext to
decrypt, so its `resolution_position` landed one slot off from where
quartz had put the ciphertext for the existing member. The AEAD tag
naturally mismatched and the commit died with
`InvalidCommit(UpdatePathError(UnableToDecrypt))` — the blocker behind
every test that needs an existing openmls member to process a commit
produced by quartz (add, rename, remove, leave, promote, catchup).
Fix: in `MlsGroup.commit()`, capture `newLeafIndices` from the Add
proposals applied in this epoch and `filterNot` them out of every
copath node's resolution before HPKE-encrypting the path secret.
https://claude.ai/code/session_016kAxdp6ubB5CnF9URhCEzP
Phase 2 of the Clubhouse/nests integration: when the live-activity
channel screen is opened on a kind 30312 MeetingSpaceEvent, render an
audio-room "stage" above the chat that shows host/speaker/audience
avatars and exposes hand-raise + mute toggles backed by NIP-53 kind
10312 presence events.
Quartz:
- New MutedTag (`["muted","1"|"0"]`) and `muted()` builder helper for
MeetingRoomPresenceEvent.
- New MeetingRoomPresenceEvent.build() overload that accepts a 30312
MeetingSpaceEvent root (matching the existing 30313 overload) and
optionally encodes hand-raise + mute flags in one call.
Amethyst:
- AudioRoomStage composable: filters participants from the 30312 event
into hosts / speakers / audience, renders avatars, and publishes
presence on enter + every 30 s while composed (on dispose it pushes
one final lowered-hand presence so peers drop us before timeout).
- ChannelView wires AudioRoomStage in next to ShowVideoStreaming; the
latter is a no-op for non-30311 events so non-audio rooms are
unaffected.
No audio is captured yet — the mic toggle is a Nostr-only signal
until Phase 3 brings the MoQ/WebTransport transport.
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
openmls (via mdk-core) strict-verifies both the FramedContentAuthData
signature and the membership_tag on every inbound PublicMessage from a
Member sender (RFC 9420 §6.1, §6.2). Quartz was framing commits with
empty placeholders for both fields, so any existing member processing a
quartz-originated commit tripped `openmls::ciphersuite: Incompatible
values` (constant-time compare of a zero-length tag against the 32-byte
expected HMAC). That's the reason every interop test that requires B to
process a commit from amy (add-member, rename, remove, leave, promote,
catchup) was failing at step one.
Fix computes both fields correctly:
* capture pre-commit groupContext bytes, membership_key, and
signingPrivateKey before the key schedule / epoch advance
* build FramedContentTBS = version || wire_format || FramedContent ||
serialized_context and SignWithLabel("FramedContentTBS", ..) with
the committer's pre-commit leaf signing key
* build AuthenticatedContentTBM = TBS || signature<V> ||
confirmation_tag<V> and HMAC-SHA256 it with the pre-commit
membership_key
No change to quartz's own processCommit path (it doesn't re-verify these
fields on inbound), so this is a one-way fix for outbound to openmls
peers.
https://claude.ai/code/session_016kAxdp6ubB5CnF9URhCEzP
Adds a new "Audio Rooms" entry in the drawer feeds section that lists
NIP-53 kind 30312 (Interactive Rooms / audio spaces) events, mirroring
the existing Live Streams architecture.
- AudioRoomsFeedFilter narrows LocalCache.liveChatChannels to
MeetingSpaceEvent (30312) and MeetingRoomEvent (30313), sorting by
OPEN > PRIVATE > CLOSED then by follow participation.
- AudioRoomsFilterAssembler/SubAssembler reuse makeLiveActivitiesFilter
so the wire-level REQs are shared with the Live Streams screen.
- AudioRoomsScreen/TopBar/FeedLoaded follow the Live Streams layout and
render via ChannelCardCompose.
This is phase 1 of the Clubhouse/nests integration plan: it only adds
the discovery surface. Joining, presence, audio transport (MoQ) and
room creation will arrive in subsequent PRs.
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D