Aligns the toggle with its Security Filters siblings (warnAboutPostsWithReports,
filterSpamFromStrangers, etc.): stored in AccountSecurityPreferences{Internal},
synced across devices via the app-specific data event, and updated through
account.updateDisableClientTag -> sendNewAppSpecificData.
The signer wrapper now reads the live value from synced settings, so changes
still apply immediately to the next signed event.
https://claude.ai/code/session_01KhNVTm8LLdVphqyxVkZtDS
Adds a "Don't add client tag to my events" switch under Security Filters.
NostrSignerWithClientTag now consults a runtime predicate at sign-time, so
the toggle takes effect immediately on the live session without rewrapping
the signer or rebuilding Account.
https://claude.ai/code/session_01KhNVTm8LLdVphqyxVkZtDS
Several user-visible error messages on Desktop are rendered with plain
`Text` composables, which means they can't be selected or copied. That
makes it awkward to share an error in a bug report or paste a hex error
string into a search.
Wrap the error text in `SelectionContainer` at the canonical sites:
- `commons.ui.components.LoadingState`: wrap the description in
`EmptyState` and the message in `ErrorState`. `EmptyState` is reused
as the in-feed error renderer (e.g. 'Error loading feed' with the
underlying error in `description`), so this covers feed/loading
errors across screens that use these helpers.
- `ComposeNoteDialog`: wrap the validation error and the upload error
in the compose-note dialog.
- `auth/LoginCard` (Nostr Connect): wrap the connection error.
- `auth/KeyInputField`: wrap the supporting-text error so the inline
message under the nsec input field can be copied.
No visual changes \u2014 `SelectionContainer` does not affect layout or
styling. Selection works on Compose Desktop (mouse drag) and on Android
(long-press) without further changes.
Replace manual relay subscribe + Channel approach with the proven
SearchBarState + rememberSubscription pattern (same as NewDmDialog).
This properly handles relay connection lifecycle and NIP-50 search,
returning results from all connected relays.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Hoist author search state to FeedsDrawerTab (AlertDialog can't run LaunchedEffect)
- NIP-50 relay search via connected relays with Channel-based result streaming
- 28dp UserAvatar in author suggestion rows
- "No users found" empty state
- 32dp dialog margins, 300dp max results height, 30 result limit
- FindUsersTest: 8 tests verifying cache search with/without metadata
- Fix: use connectedRelays instead of unconnected DEFAULT_SEARCH_RELAYS
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Brazilian Portuguese's 'one' quantity matches both 0 and 1, so a
hardcoded '1' is wrong when the count is 0. Switch the 'one' branch
of scheduled_posts_subtitle_due_suffix and scheduled_posts_relay_count
to use the %d argument like the 'other' branch.
https://claude.ai/code/session_01UmYZfFy8QqaDaXbcXguu8f
Lint flagged the notify() call as MissingPermission because there was no
visible check that POST_NOTIFICATIONS was granted. Gate the call behind
areNotificationsEnabled() — matching the pattern used in
EventNotificationConsumer — and catch SecurityException for the
narrow window where permission could be revoked between the check and
the notify().
The mid-stream resub pattern (resub1 at events.size==1, resub2 at
events.size==5) has four receive() calls between the two subscribe
calls. On a slow CI worker that's enough time for resub1
(filtersShouldIgnore, kind 10002 limit 500) to actually reach the
relay and start streaming events before resub2 replaces it, so the
loop's second EOSE can come from filtersShouldIgnore — which can
deliver up to 50 events (the preloaded count for that kind), well
past the previous 1..11 bound.
Drop the phase-specific count asserts and verify only structural
invariants: exactly two EOSEs, the loop stopped on the second, every
non-EOSE entry is a valid 64-char id, and the total event count
stays within the worst-case envelope.
https://claude.ai/code/session_01L4qS7BhX3L7ApiS3HsCozn
Three small follow-ups from the audit pass:
1. Drop redundant `challengeData.copyOf()` in
`queuePathResponseLocked` — the parser produces a fresh
ByteArray per PATH_CHALLENGE via `QuicReader.readBytes`'s
`copyOfRange`, so the defensive copy was a wasted allocation.
One-line fix.
2. Rename `pendingPathResponses` → `pendingPathChallengePayloads`.
The queue holds inbound CHALLENGE payloads we owe RESPONSES
for — old name conflated the two. Pure rename across
QuicConnection / Parser / Writer / PathValidationTest.
3. Extract shared `newConnectedClient(...)` test fixture
(`ConnectedClientFixture.kt`). The 6 test files each repeated
~40 lines of identical handshake-pipe boilerplate; folded into
one parameterized helper accepting transport-cap overrides.
Net −164 lines across the test tree; per-test helper is now a
one-liner that documents the cap shape.
No behavior change. Full quic suite + amethyst hook test green.
https://claude.ai/code/session_018KPKWRg5baX5Anf7zfEyec
Conflicts:
- quartz/.../LiveEventStore.kt: main added IngestQueue (group-commit)
and a fire-and-forget submit() path; this branch replaced the
SharedFlow live fanout with FilterIndex-driven dispatch. Resolved
by keeping main's submit/insert pipeline shape (IngestQueue ingest
ctor param, submit() callback, insert() wrapping submit via
CompletableDeferred) but routing the on-Accepted fanout through
FilterIndex.candidatesFor instead of newEventStream.tryEmit.
Added private fanout(event) helper. Kept main's
snapshotIdsForNegentropy method.
- geode/.../LoadBenchmark.kt: both sides added a new @Test method.
Kept fanoutScaling (this branch) and publishPipelinedSingleClient
(main).
Soak target #4 — minimum viable path validation. Lands the
spec-required peer-initiated case so a server probing the path
(e.g. after a NAT rebind, or post-CID rotation) sees a matching
PATH_RESPONSE and doesn't declare the path dead.
Pre-fix the parser decoded PATH_CHALLENGE / PATH_RESPONSE bytes
but threw the result away — a peer's challenge went silently
into the void. After ~3 RTT of no response, a strict peer would
mark the path dead and tear the connection down (visible to
audio-rooms users as a sudden cut on a phone that briefly
switched cells).
Implementation:
- Add PathChallengeFrame / PathResponseFrame data classes;
wire decode and encode (was decode-and-discard previously).
- Add `pendingPathResponses` queue on QuicConnection (bounded at
MAX_PENDING_PATH_RESPONSES = 64 to defend against challenge
flood; excess silently dropped — peer retries on PTO).
- Parser handler queues a response on inbound PATH_CHALLENGE.
- Writer drains the queue in buildApplicationPacket. RFC 9000
§13.3 doesn't list PATH_RESPONSE as ack-eliciting-and-
retransmittable; if a response is lost, the peer's next
PATH_CHALLENGE re-queues it and we respond again.
Out of scope for this landing (multi-day each, parked unless
production evidence requires):
- Client-initiated migration: requires UdpSocket replacement,
new-CID acquisition tracking, validating new path BEFORE
moving traffic to it.
- Anti-amplification on unvalidated paths (RFC 9000 §8.1).
Tests (PathValidationTest, 6 cases):
- PATH_CHALLENGE / PATH_RESPONSE codec round-trip + 8-byte
length validation.
- End-to-end: peer PATH_CHALLENGE → client PATH_RESPONSE
with byte-equal payload.
- Multi-challenge fan-in: 3 challenges → 3 distinct responses
(in any order; matched by content).
- Flood cap: 256 challenges → ≤ MAX_PENDING_PATH_RESPONSES
responses, connection stays CONNECTED.
https://claude.ai/code/session_018KPKWRg5baX5Anf7zfEyec
Working through the punch list from the prior "what's left?" status.
#2 close-under-load (CloseUnderLoadTest, 3 cases)
==================================================
Pins three races between connection close and active stream
traffic that the existing idle-driver close test doesn't cover:
- closeWhileBulkStreamRetirementIsRunning — server ACKs 100
in-flight client-bidi streams in one shot, retire pass + close
fire concurrently. Asserts CLOSED status and no Flow leak.
- closeWhileAppCoroutinesAreOpeningStreamsDoesNotDeadlock —
pins the lock-ordering invariant: streamsLock (openers) and
lifecycleLock (close) don't fight.
- closeWhilePeerStreamsAreInFlight — close fires mid-stream of
50 server-uni group streams (half FIN'd, half not). Every
incoming Flow terminates promptly with whatever bytes the
parser had already delivered.
#3 PN-gate / try-previous-fall-through-to-next for key updates
==============================================================
Closes the KNOWN-LIMITATION I documented in the prior round.
QuicConnectionParser previously routed mismatched-KEY_PHASE
packets unconditionally to previousReceiveProtection if non-null,
which silently dropped consecutive-rotation packets (KEY_PHASE
wraps back to its prior value, prior keys are now wrong, AEAD
fails, connection wedges).
Fix follows neqo's shape: try previous keys; on AEAD failure fall
through to next-phase derivation. Two AEAD attempts on a
mismatched-phase packet are cheap; KEY_PHASE mismatch is rare.
The previously-disabled twoConsecutiveRotationsCommitCorrectly
test now passes.
#4 loss harness depth (MoqLiteLossHarnessTest, 3 added cases)
=============================================================
First-pass harness from the previous round was a single 5%-loss
moq-lite shape. Added:
- listenerToleratesPacketReorderingOnGroupStreams — random
permutation of 50 group-stream datagrams, asserts 100%
delivery. Pins the reorder contract for moq-lite.
- listenerSurvivesExtremeTwentyPercentLoss — 200 streams at
20% loss, asserts ≥ 60% delivery and connection stays
CONNECTED. Catches catastrophic-collapse regressions in
flow-control / ACK-tracker / retired-id ring under stress.
- reliableBidiStreamRecoversFromMidStreamPacketLoss — drops
the middle two of four STREAM frames on a reliable bidi
stream, retransmits, asserts the consumer surfaces the full
contiguous payload. Pins the reliability contract distinct
from the best-effort moq-lite path.
#1 foreground-resume recycle (AppForegroundRecycleHook, 5 tests)
================================================================
Closes the user-visible production gap. ReconnectingNestsListener
already orchestrates retry on terminal state and observes
NestNetworkChangeBus for network-handover recycles. The missing
piece was a foregrounding signal: when Android reclaims the app's
UDP socket FD after backgrounding (typical at ~30 s+, network
itself still up so the connectivity callback doesn't fire), the
QUIC connection sits dead until the next send-loop throw — which
landed last round.
This hook publishes a NestNetworkChangeBus event when the app
returns to foreground after spending ≥ 5 s in background. The
pre-existing wiring observes that event and calls
recycleSession() on every active listener / speaker. Pure-state
core (AppForegroundCounter) is testable without Robolectric;
JUnit-4 unit tests pin the threshold logic, multi-activity
counter behaviour (e.g. PIP), and consecutive-cycle correctness.
Wired into Amethyst.Application.onCreate via
registerActivityLifecycleCallbacks.
https://claude.ai/code/session_018KPKWRg5baX5Anf7zfEyec
#2 KEY UPDATE VERIFICATION (soak target #2)
Added KeyUpdatePeerInitiatedTest pinning the RFC 9001 §6 peer-initiated
1-RTT key update path against InMemoryQuicPipe:
- peerInitiatedRotationCommitsAndMirrorsOnSend — single rotation
flips currentReceiveKeyPhase, mirrors currentSendKeyPhase, retains
pre-rotation keys as previousReceiveProtection, installs new
receive+send protections; connection stays CONNECTED.
- reorderedPacketOnPriorKeysStillDecryptsAfterRotation — packet
sent before peer rotated but arriving after the rotation
triggering packet decrypts via previousReceiveProtection (RFC
9001 §6.1 reorder window).
- postRotationOutboundPacketCarriesNewKeyPhaseAndDecryptsForPeer —
writer stamps currentSendKeyPhase into the short header AND
encrypts with the rolled-forward send keys.
Test infrastructure: InMemoryQuicPipe grows rotateServerApplicationKeys
(walks the same HKDF-Expand-Label "quic ku" dance the production peer
would) plus buildServerApplicationDatagramWithPriorKeys (re-emits via
the stashed pre-rotation TX, exercising the reorder-window path).
Documented limitation: consecutive rotations within the reorder window
mis-route via previousReceiveProtection. The spec-correct fix is to
gate previousReceiveProtection on a packet-number threshold (neqo /
picoquic shape); for the audio-rooms 3-hour scenario, a single
rotation is the realistic case so this is a follow-on rather than a
blocker. No test asserts the broken behaviour.
#3 RECONNECT-ON-FOREGROUND (soak target #3)
ReconnectingNestsListener already has all the orchestration
(exponential-backoff retry, JWT-refresh recycle, recycleSession()
hook for platform network-change events). What was missing at the
QUIC level: when the OS reclaims the UDP socket FD while the app is
backgrounded, socket.send() throws and the bare exception escapes
the SupervisorJob silently. The connection sits in HANDSHAKING /
CONNECTED indefinitely and the orchestrator's terminal-state
listener never fires — the room screen shows "live" while audio
is dead.
Wrapped sendLoop in try/catch mirroring the existing readLoop's
finally block: any uncaught Throwable (CancellationException
excepted, since close() is already driving teardown) calls
markClosedExternally with the cause. Also wired markClosedExternally
to record closeReason on first-call so observability surfaces the
human-readable cause through to NestsListenerState.Failed.reason.
Pinned by socketDeathMidSessionFlipsConnectionToClosed —
runs the driver, tears the UDP socket out from under it, asserts
status flips to CLOSED within 5 s and the close reason mentions the
loop death. Pre-fix this would loop forever waiting for status to
move.
Tests:
- KeyUpdatePeerInitiatedTest (3 cases)
- QuicConnectionDriverLifecycleTest::socketDeathMidSessionFlipsConnectionToClosed
https://claude.ai/code/session_018KPKWRg5baX5Anf7zfEyec
Follow-up to b8c6e080 addressing the gaps I called out in the
"is this the best we can do?" reply.
1. **Heap-sampling soak test** (`QuicHeapSoakTest`). Long-form, default-
skipped via `-PquicSoakSeconds=N` propagated by `quic/build.gradle.kts`
to the jvmTest task. Without the property the test early-returns with
a printed SKIPPED line, so `./gradlew test` stays fast for CI. With
the property, drives moq-lite-shaped peer-uni churn at ~50 streams/s
for N seconds, samples `totalMemory - freeMemory` six times across
the run, and fails if the post-warmup → final delta exceeds 10 MB
(the acceptance threshold from the audio-rooms soak prompt).
Production use: `-PquicSoakSeconds=1800` for the 30-minute soak.
2. **FD-leak canary** added to `QuicConnectionDriverLifecycleTest`. On
Linux, samples `/proc/self/fd` size before / after the 100-session
loop; banded at +16 entries for ambient JVM noise. macOS / Windows
silently no-op because /proc isn't there. Catches socket / pipe
leaks the thread-count check would miss.
3. **Phantom-stream guard.** Added `retiredStreamIdSet` (capped FIFO
ring at 4 096 entries, ~80 s of moq-lite churn) plus
`isStreamIdRetiredLocked` on the connection. Parser checks before
`getOrCreatePeerStreamLocked` and drops STREAM frames the peer
retransmits on already-retired streams. Eliminates the
"duplicate ACK lost → peer retransmits FIN → we mint a phantom
QuicStream" edge case I papered over in the previous commit.
Pinned by `phantomGuardDropsRetransmitOnRetiredPeerStream`.
4. **moq-lite loss harness** (`MoqLiteLossHarnessTest`). First pass at
soak target #5: drive 50 best-effort group streams with 5%
uniform packet loss, assert the listener surfaces ≥ 90% with
payloads intact and the connection stays CONNECTED. Out of scope
here: reorder injection, latency-under-loss measurement, full
end-to-end with a real moq-lite publisher.
Tests:
- `QuicHeapSoakTest` — gated, validates 10MB heap acceptance band.
- `QuicConnectionDriverLifecycleTest::repeatedSessionLifecycleDoesNotLeakThreads`
— now also enforces /proc/self/fd bound.
- `StreamRetirementSoakTest::phantomGuardDropsRetransmitOnRetiredPeerStream`
— pins the duplicate-frame drop semantics.
- `MoqLiteLossHarnessTest` — 2 cases (lossy + lossRate=0 baseline).
https://claude.ai/code/session_018KPKWRg5baX5Anf7zfEyec
Soak target #1 from the audio-rooms hardening pass: moq-lite over QUIC
mints one peer-uni stream per Opus frame, so a 3-hour broadcast at
~50 frames/sec accumulated ~540 000 stream entries in
`QuicConnection.streamsList` / `streams` for the lifetime of the
session. The two structures were append-only — closed streams were
filtered out of the writer's iteration but never removed — and the
heap grew monotonically.
Adds `QuicStream.isFullyRetired` plus `retireFullyDoneStreamsLocked`
on the connection. The writer drains the retire pass at the top of
`buildApplicationPacket`, dropping streams whose send side has
peer-acked FIN/RESET and whose receive side has both FIN'd and
fully drained into the application's incoming Channel. The
cumulative receive high-water folds into `retiredStreamsRecvBytes`
so the connection-level MAX_DATA accounting in
`appendFlowControlUpdates` keeps advertising the lifetime total —
without that seed, retiring K bytes would silently regress the
peer's send credit.
Also adds soak target #6 coverage: `QuicConnectionDriver` now
exposes `driverJob` / `closeTeardownJob` for test assertion, and
the new `QuicConnectionDriverLifecycleTest` cycles 100 sessions
against a localhost UDP blackhole to pin idempotent close +
bounded thread growth.
Tests:
- `StreamRetirementSoakTest` (4 cases): local-uni FIN+ACK
retirement, peer-uni listener-path retirement, MAX_DATA accounting
preservation across retire, and a 10 000-stream churn harness
that asserts the working set stays bounded.
- `QuicConnectionDriverLifecycleTest` (2 cases): close idempotency
and 100-session thread-leak canary.
https://claude.ai/code/session_018KPKWRg5baX5Anf7zfEyec
- Delete `strfryDrivesGeodeAsServer` — boots strfry but uses
kmp-negentropy ↔ Geode, no actual strfry-vs-geode interop.
Duplicates `Nip77NegentropyTest.negentropyComputesSymmetricDifference`.
- Strip speculative `negentropy { enabled = ... }` and `nofiles`
blocks from the strfry config; defaults are what we want to test.
- `InteropSyncDriver.reconcile` → `negotiate`. The function
computes the symmetric difference; it doesn't move events.
Convert to `suspend fun` to drop a nested `runBlocking` that
could deadlock under dispatcher pressure.
- Inline `pullSync` helper in GeodeVsGeodeNegentropySyncTest —
it was a one-line wrapper with a misleading name.
- Batch `relayB.preload(needFromA)` instead of looping.
- Tighten `idsOnRelay(NormalizedRelayUrl)` signature (was
re-parsing the string on every call).
- Drop redundant `assertTrue(size > cap)` after `assertEquals(11)`.
New geode/src/test/kotlin/com/vitorpamplona/geode/interop/ folder
mirrors strfry's test/syncTest.pl over real WebSocket frames.
- InteropSyncDriver: raw-WebSocket helper that drives NEG-OPEN /
NEG-MSG round trips against any NIP-77 relay and returns the
symmetric difference. No NostrClient indirection — same wire shape
strfry sync uses.
- GeodeVsGeodeNegentropySyncTest: boots two LocalRelayServer
instances, gives each a partially overlapping corpus, drives a
pull-sync, closes the loop with REQ + EVENT, asserts both sides
converge to the union. Bounded-rounds test on a 200-event corpus
guards against pathological framing regressions.
- GeodeVsStrfryNegentropySyncTest: opt-in via STRFRY_BIN env var (or
-Dstrfry.bin=...). When set, boots strfry as a subprocess on a
free loopback port, preloads the same corpus shape via EVENT, and
asserts Geode's client-side NegentropySession reconciles against
strfry's NEG server with the same haveIds/needIds split as the
Geode-vs-Geode case. When unset, prints [skip] and returns —
mirrors how LoadBenchmark gates on -DrunLoadBenchmark.
Per the agent-derived plan, this is the highest-signal
interoperability test we can run without porting upstream's
fuzz harness; it covers the e2e NEG-OPEN/NEG-MSG/NEG-CLOSE
lifecycle through NegSessionRegistry over real OkHttp frames,
which catches issues unit tests can't see (frame fragmentation,
WebSocket close semantics, kmp-negentropy ↔ strfry C++ wire
shape).
Two coupled gaps surfaced when running the runner's zerortt testcase
against aioquic (picoquic + quic-go already passed because they
fault-tolerate harder).
1) Wire format. The 0-RTT pre-handshake batch was sending raw
"GET /<path>\r\n" on bidi streams regardless of ALPN. aioquic's
h3 server accepts 0-RTT at the TLS layer (early_data extension
echoed in EE) but its h3 layer silently drops bidi streams whose
payload isn't a valid HEADERS frame — server log shows N "Stream
X created by peer" lines and zero responses. Switch the
pre-handshake builder to fork on the cached ALPN: h3 →
Http3GetClient (three uni control streams + HEADERS-framed bidi
requests via prepareRequests); else → HqInteropGetClient (raw
text). The post-handshake side then reuses the pre-handshake
client and collects responses via awaitResponse(handle), so 1-RTT
replay (after rejection) lands on the right parser.
2) TLS-layer rejection. When the server skips the early_data
extension in EncryptedExtensions, the client must replay all
in-flight 0-RTT app data through the 1-RTT keys (RFC 9001 §4.6.2).
TlsClient now exposes earlyDataAccepted, set in the
WAITING_ENCRYPTED_EXTENSIONS branch. QuicConnection's
onApplicationKeysReady checks it: if 0-RTT was offered but EE
didn't carry early_data, we requeueAllInflightStreamData() +
cryptoSend.requeueAllInflight() + sentPackets.clear() BEFORE
installing 1-RTT keys, so the next writer drain ships the
identical stream/CRYPTO bytes under 1-RTT protection. Same
stream handles, same response collection — invisible to the
request layer.
Result, ./quic/interop/run-matrix.sh -t zerortt:
aioquic ✓(Z)
picoquic ✓(Z)
quic-go ✓(Z)
Resumption sweep regression-clean across all three.
334 :quic unit tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Align the NIP-77 large-corpus plan with strfry's actual defaults:
500_000-byte frame cap (not 64 KB), no default since-window, 200
shared sub cap, max_sync_events overflow protection, exact NEG-ERR
wording, and 32-byte id storage. Add a strfry round-trip benchmark
and call out the BTreeLMDB pre-built tree as a follow-up.
Tier 3 used to say operators "must omit VerifyPolicy from their
policy chain" when parallelVerify is on — that turned out to be
the AUTH-verify regression caught in the audit. Updated the plan
to describe the real wiring: VerifyPolicy was split into a
parameterised base with two singletons, and composePolicy swaps in
VerifyAuthOnlyPolicy so AUTH commands keep signature verification
even when the IngestQueue takes EVENT verify.
Removes the `hang-interop` + `browser-interop` jobs from
`build.yml` (added in commit `21947bc5` after a 10/10 stability
sweep × 22 tests = 220/220 hard-pass). Cold-cache cost is
~10 min hang + ~13 min browser; warm-cache critical-path add
is ~6 min via the parallel browser job. Most PRs don't touch
audio / MoQ / QUIC, so paying that cost on every PR is
net-negative for the change-pattern the repo sees.
Adds `nestsClient/tests/README.md` covering:
- when to run (changes to nip53 / moq-lite session / audio
pipeline / MoqLiteNests* / ReconnectingNests* / :quic /
the sidecars themselves);
- quick-start gradle commands for hang-only, browser-only,
combined;
- prerequisites + first-run cache costs;
- all the configuration knobs (incl. the
`-DnestsHangInteropTraceRelay=true` trace-capture switch
added during the routing investigation);
- known limitations (hot-swap browser soft-pass,
framesPerGroup pin-vs-prod gap, I7 cycle 2 truncation);
- a 4-step debug recipe for triaging a flaking scenario.
Plan updates:
- `2026-05-07-t16-closure-roadmap.md` — Priority 3 marked
⏸ DEFERRED instead of ✅ CLOSED, pointing at the new README.
- `2026-05-07-cross-stack-interop-ci-gating.md` — status
changed to ⏸ DEFERRED; YAML shape preserved verbatim in the
plan for the next revisit.
- `2026-05-06-cross-stack-interop-test-results.md`,
`2026-05-06-cross-stack-interop-test-gap-matrix.md` — CI
integration § rewritten to "manual-run only" + README link.
The trace-capture instrumentation in `NativeMoqRelayHarness`
stays in place; it's useful for future flake triage even
without CI.