Implements the 10 actionable items from the QUIC code review in issue #2861;
the lone P4 item (encrypt-under-streamsLock) is already tracked as deferred
phase 2 work in quic/plans/2026-05-08-lock-split-design.md and is unchanged.
Android-only correctness (would silently break on API 26–32):
- A1 JdkCertificateValidator: wrap Signature.getInstance("Ed25519") in
try/catch and surface NoSuchAlgorithmException as a clean
QuicCodecException so an Ed25519 leaf cert no longer crashes the
TLS read loop on pre-API-33 Android.
- A2 TlsRunningSha256 (jvmAndroid): keep a parallel byte accumulator and
fall back to one-shot SHA-256 on the rare API-26–28 Conscrypt builds
whose OpenSSLMessageDigestJDK throws CloneNotSupportedException from
MessageDigest.clone(). Latches the fallback after first failure so we
don't pay the JCA throw per snapshot.
Hot-path performance:
- P1 HeaderProtection: add maskAt(hpKey, src, srcOffset) + extend
AesOneBlockEncrypt with encryptInto(key, src, srcOffset, dst, dstOffset)
so the per-packet HP path no longer allocates a 16-byte sample slice
AND no longer allocates a 16-byte JCA Cipher output (the jvmAndroid
impl uses Cipher.doFinal's range overload). Updated 5 call sites in
ShortHeaderPacket and LongHeaderPacket.
- P2 aeadNonce: add aeadNonceInto(staticIv, packetNumber, dst) so call
sites with a persistent 12-byte scratch can build the nonce without
per-packet allocation; aeadNonce keeps its existing shape via the new
helper. Threading the scratch through Short/LongHeaderPacket and the
writer is deferred (similar shape to the documented P4 phase 2 work).
- P3 QuicConnectionParser: decode the frame list once per inbound packet,
feed both qlog (frameNamesFor) and dispatch from the single decode.
Pre-fix every qlog-attached packet ran decodeFrames twice.
- P5 QuicConnectionWriter: iterate pendingMaxStreamData /
pendingNewConnectionId directly instead of allocating
entries.toList() per drain.
Protocol / security:
- S1 QuicConnectionParser: cap MAX_STREAMS at 2^60 (RFC 9000 §19.11);
a peer sending a larger value now triggers STREAM_LIMIT_ERROR close
rather than overflowing the local nextLocalBidi/UniIndex counters.
- S2 QuicConnection.effectiveResumption: drop the cached session ticket
when (now - issuedAt) ≥ min(ticketLifetimeSec, 7 days) per RFC 8446
§4.6.1; expired tickets would otherwise silently fail server-side and
lose any 0-RTT bytes.
- S3 QuicConnection: refuse to offer 0-RTT when our current alpnList
doesn't include the resumed session's negotiated ALPN, and treat
0-RTT as rejected on EE if the new ALPN differs from the cached one
(RFC 9001 §4.6.1).
- S4 TlsExtension.encodeSignatureAlgorithms: drop rsa_pkcs1_sha256.
The validator already rejects it in CertificateVerify per RFC 8446
§4.2.3 — advertising it lied about what we accept.
All quic JVM unit tests pass; spotless applied.
https://claude.ai/code/session_01EBHtGLy5o7FUR5qfcpHUUx
Adds Czech, German, Brazilian Portuguese, and Swedish translations
for the 6 new muted-threads keys introduced with the mute-thread
feature (action_unmute, quick_action_mute_thread,
quick_action_unmute_thread, settings_muted_threads_empty,
settings_muted_threads_title, settings_muted_threads_unknown).
- tighten placeholder dictionary after review
- Skip the placeholder scan when the source has no `{` (avoids Matcher
allocation on the common path).
- Clamp `firstFreeIndex` to PLACEHOLDER_LIMIT so adversarial user text
like `{99999}` can't push our counter past the table limit and make
placeholder() throw for the rest of the note. New JVM test covers it.
- split clamp and max-update in firstFreeIndex
PUA codepoint placeholders (+) were being dropped by ML Kit's
Japanese/Chinese/Korean models, eliding image URLs from translated text
(issue #1180). On-device probing across 8 source languages and 12
placeholder styles shows ICU MessageFormat tokens `{0}`, `{1}`, … are
the only style preserved intact by every model we tested.
build() now scans the source for any user-supplied `{N}` and starts the
dictionary counter above the max, so a note containing `printf("%s", arg{0})`
can't be clobbered by encode/decode. Adds an on-device regression test
with the exact post from the bounty issue and JVM tests for the new
collision-avoidance behaviour.
The previous attempt lit the dot whenever *any* enabled home tab had unread
items, so a user with all three tabs enabled who only scrolled through the
Everything tab still saw the dot — HomeFollows / HomeFollowsReplies stayed at
their old lastRead even though the same content had been read via Everything.
Switch to "every enabled tab still has unread" — equivalently, reaching the
top of any single enabled tab clears the dot. This matches the pre-bug
behavior (where only the New Threads tab gated the dot) while still respecting
users who only enable the Everything tab.
BUD-01 defines a Blossom URL as `<server>/<sha256>[.<ext>]` — the blob hash is
always the last path segment. Walking the path right-to-left for any hex
match was too permissive: a non-Blossom URL like `https://example.com/<sha>/avatar.jpg`
(sha appears in an intermediate segment) would get incorrectly bridged.
Both parsers now look at the last path segment only and skip the URL entirely
when it isn't a sha256. The earlier hex-prefix case (share.yabu.me's
`<cache-prefix>/<blob>.ext`) still works because the blob is still the last
segment; the prefix flows into `xs` via the existing `buildServerBase` /
`extractServerBase` logic. Adds negative tests covering the
sha-in-non-last-segment case in both modules.
Building the URL from extracted prefix/blob constants could mask a parser bug
that splits the path the same way the test constructs it. Use the full URL
from the bug report as a single literal so the test only agrees with the
parser if the parser actually parses the URL correctly.
CDNs like share.yabu.me serve blobs under `<cache-prefix-sha>/<blob-sha>.<ext>`
where both path segments are 64-char hex. The bridge previously locked onto the
first match (the cache prefix), dropping the blob segment from `xs` and asking
the local cache for a non-existent blob. Walking the path right-to-left makes
the rightmost sha — the one that carries the file extension — win, while the
prefix segments stay in `xs` so the cache can fetch upstream on miss.
Behaviour is unchanged when the path has a single sha; tests cover the new
two-hash layout in both the Coil-model path and the OkHttp interceptor path.
Pulled from a retrospective on a feature PR that exercised every rule
already in the doc, and surfaced eight gaps where the doc was silent.
- Protocol-introducing changes (new section): require an explicit
cross-client compatibility section, on-relay wire-format verification
via nak, and a NIP spec citation when claiming "NIP-X allows this".
These three together prevent the "syncs across devices" handwave
from masking real interop questions.
- Release-minified build subsection in "Build and install both
flavours": call out the existing benchmark build type
(com.vitorpamplona.amethyst.benchmark, profileable=true, R8-active)
as the cheap rig for catching ViewModel-factory / expect-actual /
serialization R8-stripping bugs before the reviewer does.
- Strip diagnostic Log.d before commit: explicit policy. Lambda Log
is required for committed logs; temporary debug Log.d gets removed.
- Regression-test-plan worked example: shows the required
### Feature test plan / ### Regression test plan structure with a
real-shaped example, so contributors don't ship the wrong subheadings.
- Multi-device sync touch-point: added to the regression sweep
checklist for features that publish per-account state to relays.
- R8-minified-build touch-point: added to the same checklist for
reflection-heavy code paths.
- "When on-device QA finds bugs in your own diff" subsection in
"Code review pass": promote the pattern of landing manual-QA
fixes as a separate commit with root-cause prose, not folding
silently into the feature commit.
- Working-notes convention in "Everything else": clarify
docs/plans/ is tracked, docs/brainstorms/ + docs/superpowers/
are gitignored. Saves AI tools from trying to commit scratch work.
Sonar — convert 'if (A) {…} else if (B) {…} else {…}' chains into
'when { A -> …; B -> …; else -> … }' across 33 files (~44 sites).
The change is mechanically equivalent: same conditions, same branch
order, same short-circuit evaluation, no public-signature impact.
The 70-branch event-type dispatch in ThreadFeedView.kt:594 is intentionally
left as if-else for now — the conversion would balloon to a 280-line diff
with no behavioural change, making review impractical.
Sonar — collapse 'if (X) { ...; return A } else { ...; return B }' to
'return if (X) { ...; A } else { ...; B }' across 5 files. Mechanically equivalent.
PlaybackService.lazyPool keeps its in-branch non-local returns from .let{} blocks
(those are early-out cache hits, not tail returns).
Sonar kotlin:S1066 — collapse 'if (A) { if (B) { body } }' into 'if (A && B) { body }'
across 16 files. Kotlin's && short-circuits identically to the nested form, so this
is a behaviour-preserving change.
- quartz: Event.threadRootIdOrSelf() now falls back to markedReply()?.eventId
when both markedRoot() and unmarkedRoot() are absent
- amethyst: After upstream PR #2855 split the notifications feed into
notificationsFollowing and notificationsEveryone, the hiddenUsers.flow
collector still only invalidated the original notifications feed. Extends
it to invalidate all three.
- amethyst: CardFeedContentState.refreshSuspended() takes an additive-only
path when lastNotes is populated — it only adds cards for new admissions,
never removing cards for notes that no longer pass the filter. Re-muting
mid-session correctly rejected muted-thread reactions in the filter, but
the existing cards stayed in the UI as "Show Anyway" placeholders. Calls
clear() before invalidateData() on each notification feed so the refresh
hits the full-rebuild branch and removals propagate.
Tighten the channel/community arm of wantsBroadcastRelays. The previous
revision dropped broadcasting relays for every PollEvent /
MeetingSpaceEvent / MeetingRoomEvent / LiveActivitiesEvent and for any
event LocalCache resolves to a channel. That's wrong when the event
itself doesn't carry a relay set — without broadcast there's no
destination left.
Only treat a channel/community event as "self-routing" when its own
relays() / allRelayUrls() (or its channel's relays()) is non-empty.
Otherwise fall through to the broadcasting list.
The previous commit unconditionally seeded broadcasting relays for every
non-private send. That over-broadcasts events that exist only for the
user (encrypted drafts, app-specific data, bookmark lists) and events
that already declare their own relay scope (polls, live activities,
meeting spaces, public chats, ephemeral chats — anything cached as a
channel).
Add `wantsBroadcastRelays(event)` and gate broadcast-list inclusion on
it. When the event opts out, also rebuild the author=userProfile()
outbox from `nip65 + privateStorage + local` directly, since
`outboxRelays.flow.value` already merges broadcast in.
Excluded event types:
* Personal storage: DraftWrapEvent, AppSpecificDataEvent,
BookmarkListEvent (+ OldBookmarkListEvent, LabeledBookmarkListEvent)
* Channel/community: PollEvent, MeetingSpaceEvent, MeetingRoomEvent,
LiveActivitiesEvent, plus any event LocalCache resolves to a
channel (public-chat kinds, ephemeral chats, live-activity chat).
The ShortNotePost compose path went through
`signAndComputeBroadcast` → `computeRelayListToBroadcast(event)`, which
only mixed in the user's broadcasting relays indirectly via
`outboxRelays` — and only when `author == userProfile()`. Two real
paths dropped the broadcast list entirely:
* Anonymous posts (`signAnonymouslyAndBroadcast`) sign with a
throwaway pubkey, so `author != userProfile()` and the branch that
pulls in `outboxRelays.flow.value` is skipped.
* `MetadataEvent` / `AdvertisedRelayListEvent` returned early with
`followPlusAllMineWithIndex + availableRelays`, neither of which
includes the broadcast list.
Hoist `broadcastRelayList.flow.value` to the top of the function (after
the `GiftWrapEvent` / `WrappedEvent` early returns that defines what
"private" means) and seed it into every non-private return path. Sets
dedupe so the regular path stays unchanged in the common case where
broadcasting relays were already reachable through `outboxRelays`.
Mirrors the quartz commonTest stub so :quic:testAndroidHostTest no longer
throws RuntimeException("Stub!") through PlatformLog.android on the
MAX_STREAMS_UNI emission path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Volatile resolves via kotlin.jvm on JVM but needs an explicit
kotlin.concurrent.Volatile import on Native; Dispatchers.IO is internal
on Native, so the in-process WebSocket switches to Dispatchers.Default
(no blocking I/O on that path); and Native's LinkedHashMap is final
without removeEldestEntry, so Nip98AuthVerifier's replay cache now caps
itself with an explicit insertion-order eviction loop (access-order was
unused — the cache is write-only).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces JVM-only @Volatile + synchronized double-checked locking with
kotlin.concurrent.atomics.AtomicBoolean.compareAndSet so the file builds
on all commonMain targets, mirroring the pattern in BasicRelayClient.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
RelayEngine (224 → 185 lines):
- Extract BanStore <-> RuntimeConfigData mapping helpers
(seedInto, snapshotOf) into geode.config — bidirectional
conversion now has names instead of being inline boilerplate
repeated between the init block and snapshot().
- Collapse the standalone init block into a .apply on the
banStore declaration. Method reference ::snapshot for the
onMutation hook.
- Drop the effectiveAtBoot field's 12-line KDoc (rename to `boot`
locally, terse). Trim verbose property KDocs to the load-bearing
facts (why-not-what).
StaticConfig (255 → 182 lines):
- Drop resolveInfo's unused `advertisedUrl` parameter and the
workaround comment that justified keeping it for "future
fields". YAGNI — add back when actually needed. Two callers
updated.
- Trim trivial KDocs (fromToml/fromFile, field names that
document themselves like require_auth, reject_future_seconds,
supported_nips, database.file).
- Collapse NetworkSection's three thread-pool KDocs (~16 lines)
into a single section-level KDoc.
- Compact parallel_verify, AdminSection, AdminSection.state_file,
and advertisedUrl companion KDocs while keeping the
load-bearing facts (invariants, strfry interop strings,
surprising defaults).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
KtorRelay was carrying state that wasn't HTTP-specific: the
Nip86Server, its InfoHolder adapter, and the adminPubkeys allow-list.
Push them into RelayEngine — it already owns the info doc, ban store,
and event store the Nip86Server consults; the allow-list is a
relay-level "who is admin?" decision, not a transport-level one.
RelayEngine gains adminPubkeys: Set<HexKey> = emptySet() and exposes
nip86Server as a public property. Future non-HTTP admin transports
(in-process tools, hypothetical NIP-86-over-WS) read it directly
without re-deriving the wiring.
KtorRelay shrinks to mostly Ktor structures — routing block, engine
config, lifecycle. It just builds the Nip86HttpHandler around
relay.nip86Server.
Main.kt and Nip86EndToEndTest pass adminPubkeys to RelayEngine
instead of KtorRelay.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>