Two exits still popped a composer while the IME was mid-animation — the race
that strands imePadding() at keyboard height app-wide.
1. Top-bar X and Post. KeyboardAwareBackHandler only guards the back gesture;
ActionTopBar wired both buttons straight to nav.popBack(), with nothing
dismissing the keyboard first. Tapping either while typing reproduced the
original bug exactly. The earlier fix leaned on the back arrow as the
"always-available exit" without noticing it was also a race source.
2. A ~250ms hole in the back gate. It read the animated WindowInsets.ime,
which stays above zero for the whole close animation — a window in which
the IME had already stopped consuming back but the handler was still
disabled, so a second back fell through to the NavController and popped
without ever running onBack. That silently dropped the draft the handler
exists to save: nothing else saves it, onCleared() only closes the writing
assistant and there is no autosave.
Both are the same underlying requirement — serialize the IME and window
animations instead of overlapping them — so both now route through one
helper, rememberAfterKeyboardCloses(): keyboard down, the action runs inline
and nothing changes; keyboard up, clear focus, hide, wait for the inset to
actually reach zero, then act. The wait is bounded so a stale inset (the very
failure being guarded) can never trap the user on screen, and re-entrant calls
are dropped since the deferral widens the window for a double-tap on Post to
fire twice.
The back gate now reads WindowInsets.imeAnimationTarget, which flips to zero
the moment the hide begins, so back keeps reaching onBack throughout the
animation. Re-enabling that early means onBack can fire mid-animation, which
is exactly what the helper absorbs.
Not covered: this is verified by compile and the unit suite only. The race
reproduces on release builds on a device, which this environment cannot run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LfUMGWYu2uTSyh17JJonfN
Deep-audit pass over the branch. Nothing here changes what a band claims;
these are the defects that pass tests and bite later.
- record() read the clock twice PER KIND. A 40-kind map took 80 readings,
and worse, a span's floor and ceiling were judged against two different
instants — so a span could be accepted at one end and rejected at the
other on a clock tick. One read, one instant, for the whole call. The
aggregate path had the same double read and now shares it.
- legs() handed the SAME MutableList instance to every Filter in a group,
publishing its accumulator through a public return value. Filters are
treated as immutable everywhere else; this keeps that true by
construction rather than by nobody having tried yet.
- The state file's round trip was asserted only for the fields, never for
the behaviour. Three tests now pin it: per-kind spans survive a restart
AND still narrow per kind afterwards; the ALL_KINDS sentinel survives
its negative key through toString/toInt; and a pre-split file (min/max,
no spans) loads as the claim it always was. Plus the rollback contract
— `min`/`max` must remain the OUTER edges, since a binary from before
per-kind spans reads those and would otherwise skip ground it has not
covered.
Checked and found sound, recorded so the next reader need not re-derive
it: ConcurrentMap.snapshot() copies, so export() cannot be mutated under
a writer; Band is immutable (widen() copies its map), so a shared Band
across threads is safe; merge() keeps old.fullAt, preserving the
re-walk clock across widening; and coveringWindow does NOT regress —
a paged band gave >1 leg before this change too, and a reconciled band
still collapses to one leg and narrows the shared snapshot.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both found in the review pass over the previous commit, both the same
shape — a band written that no lookup can reach, or reaches wrongly.
- Spans for kinds the filter never named were stored as given. Inert for
legs(), which only looks up the filter's own kinds, but NOT for
Band.minCreatedAt — and that is what SyncCoverageFile writes as its
rollback-compat `min`/`max`. A relay answering with more than it was
asked for (or a caller whose containment check runs against a
different filter than the band is keyed by) would push that floor
below anything the filter's kinds support, so a binary from before
per-kind spans would read the file and over-claim. The fix, undone
through the compatibility path it added.
- observedByKind on a filter that names NO kinds was stored per kind,
while legs() for such a filter reads only ALL_KINDS. The band was
recorded, persisted, and never consulted: a resume that silently did
not resume. Collapsed to the union, which is the only claim a
kind-less filter can make.
Why these were not in the initial diff: both live where the new per-kind
path meets an OLD assumption — that record()'s input is already scoped
to the filter, and that a band's keys are always the filter's kinds.
Neither held once callers began supplying the map themselves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A band held ONE created_at interval per (relay, filter). For a filter
naming several kinds that is a claim no walk can support: ask for
`kinds: [0, 30382]`, find profiles going back years and score cards only
from last month, and the band records 2020..now for the pair. The next
run then skips that whole interior for BOTH — so score cards written
inside it are never asked for again, and nothing anywhere says so. A
long-lived kind vouched for a short-lived one.
Band.spans is now per kind. Each carries only the evidence actually
collected for it, so the profile kind keeps its wide interval and the
score kind keeps its narrow one, and legs() re-opens the interior for
the second while still skipping it for the first.
Three things keep the cost of that where it was:
- legs() REGROUPS kinds by the windows they want. Identical coverage —
the common case, and the only case until they diverge — collapses back
into one ask, so a filter that produced two legs still produces two
rather than two per kind. Only a kind whose evidence genuinely differs
earns its own.
- A finished reconcile needs no per-kind evidence and is given none:
negentropy compares the filter's whole id set in one pass, so it
covers every kind in the filter or none. Only the PAGED path changed.
- Filters naming no kinds keep a single span under ALL_KINDS, which is
the same claim as before, correctly scoped to the case where it is the
only claim available.
record() takes observedByKind, and SyncCoverage.observe() accumulates it
as events arrive — replacing the pair of hand-rolled vars each caller
kept, and moving the per-event isPlausible guard in with it. A paged
walk over a MULTI-kind filter that supplies none earns no band at all,
loudly, once: attributing one interval to every kind is exactly the
over-claim this removes, and a band that over-claims skips events
silently, which is worse than re-reading them. Single-kind filters are
untouched — there the aggregate always was the per-kind answer.
The state file gains a per-kind `spans` object and keeps `min`/`max` as
the outer edges, so a rollback to a binary from before this reads the
file and behaves as it always did. A file written BEFORE this loads its
one interval under ALL_KINDS — the old, wider claim, kept rather than
discarded because discarding it would re-download every upstream's
corpus once on upgrade. The first per-kind walk replaces it.
All 26 existing SyncCoverage tests pass unchanged, which is the evidence
that single-kind behaviour did not move. The five new ones were checked
against the pre-fix rule reinstated in place: the two behavioural ones
fail there and pass here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The thread reply composer (and every other full-screen draft-saving editor)
still consumed back with a raw `BackHandler`, so `KeyboardAwareBackHandler` —
added for exactly this case — only protected the three chat composers.
Popping the screen while the keyboard is still up races the predictive-back
window animation against the IME close animation. When the window animation
wins, the IME `WindowInsetsAnimationCompat` is cancelled before its terminal
zero frame reaches Compose, the shared `WindowInsets.ime` holder stays
"animating", and every `Modifier.imePadding()` freezes at keyboard height —
the keyboard vanishes but its padding stays behind, even after leaving
the screen.
Switching these composers to `KeyboardAwareBackHandler` lets the first back
(or back-swipe) fall through to the system, which dismisses the keyboard with
its own animation that completes cleanly; the next back saves the draft and
pops as before. The top bar's cancel arrow remains an always-available exit.
Covers `ShortNotePostScreen` (which also backs `PollPostScreen`),
`GenericCommentPostScreen`, `LongFormPostScreen`, `NewProductScreen`,
`NewPublicMessageScreen`, `NewGoalScreen`, `NewWorkoutScreen` and
`AwardBadgeScreen`. `VoiceReplyScreen` keeps the plain handler — it has no
text input or `imePadding()`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LfUMGWYu2uTSyh17JJonfN
Audit follow-up to the quote fix. The path, query and fragment readers only
stop on a space, and readEnd dropped a single trailing delimiter, so a quoted
link that closed a sentence kept its quote:
He linked "https://example.com/some/path". -> https://example.com/some/path"
(see "https://example.com/some/path") -> https://example.com/some/path"
readEnd now strips the tail in a loop. The balance check runs on every round,
so a url that legitimately ends in a matched closer still stops the strip:
`[link](…/Bitcoin_(disambiguation)).` keeps `(disambiguation)` and drops the
`).` that belongs to the sentence.
Differential run over a 4000-string corpus against the previous commit: 8 rows
change, every one of them the removal of extra trailing punctuation. No url is
gained, lost or truncated mid-string.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKCAegYMF9V9Nb8FHcMvT7
A bare host wrapped in quotes ("relay.momostr.pink") was detected with the
opening quote attached, so the rendered link read `"relay.momostr.pink` and
pointed at a host that does not exist. The mirror case was also wrong: a
quoted url with a path/query/fragment kept the closing quote, because those
readers only stop on a space.
Quotes are not host characters, so they now end the current token exactly
like a space does in readDefault (covering the leading quote and a quote
glued to a previous word, e.g. `href="www.google.com"`), and they were added
to CANNOT_BEGIN_URLS_WITH / CANNOT_END_URLS_WITH so a trailing quote read as
part of a path, query or fragment is stripped on readEnd. The set covers the
ascii quotes plus the typographic family, including the guillemets below the
international-character threshold that the ascii boundary rule never cut.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKCAegYMF9V9Nb8FHcMvT7
Follow-up to 8f8713d8 (nostr proposal 259a0bb1). CLAUDE.md forbids
fully-qualified class names inline in function bodies; the merged
proposal introduced one (androidx.compose.runtime.LaunchedEffect) and
the file already carried four more that predate it. Import them all and
reference them by simple name: LaunchedEffect, snapshotFlow,
rememberCoroutineScope, LocalWindowInfo.
Also rewrites two comments the proposal added:
- the auto-enable comment was written in the first person and described
the author's own earlier mistake; restate it as what the code does and
which two paths it covers.
- the "Turn on desktop notifications" comment claimed the button renders
only when the user explicitly disabled notifications, but the guard is
`!enabled` alone. Describe the actual condition and why it is enough.
Drops a redundant `enabled = true` on that OutlinedButton (the default).
No behaviour change. :desktopApp:compileKotlin and :commons:jvmTest green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Audit of the IPv6 work found a family of bugs in isLocalHost/isOnion, most
predating this branch, all with one root cause: the predicates ran `contains`
over the whole url rather than parsing the authority. These decide whether a
relay is exempt from Tor, and relay urls arrive from other people (NIP-65
lists, relay hints, r tags), so they are attacker-controlled input.
- A path could impersonate the host. `wss://evil.example.com/127.0.0.1`
answered isLocalHost() == true, so any relay list could hand the app a url
that silently dropped its own Tor routing. The IPv6 lookup added earlier on
this branch had the same flaw via `/[fd00::1]`, and IPv6 canonicalization
could rewrite a path outright, corrupting the url.
- `.onion:8080` never matched the `.onion/` test, so an onion relay on an
explicit port was not treated as onion at all: never forced onto Tor, and its
hostname went to the clearnet DNS resolver. The fully-qualified `.onion.`
spelling missed the same way.
- Host tests were case-sensitive, but fix() asks them before the RFC 3986 pass
folds case, so LOCALHOST:8080 and ABC.ONION:8080 were handed a wss:// scheme
neither host can serve.
- Private IPv4 was substring-matched, which missed 10.0.0.5, 172.16.3.4 and
127.1.2.3 — a LAN relay got wss:// and was dialed through Tor — while
matching 192.168.evil.com and 127.0.0.1.evil.com, registrable domains that
could therefore exempt themselves from Tor. Same for notlocalhost.example.com
against `contains("localhost")`.
- A `://` inside a path was read as a scheme separator, so
`relay.com/x://127.0.0.1` read its path as the authority.
Fixes: a shared hostStart/hostEnd/hostEndWithoutPort trio bounds every test to
the authority, strips :port and trailing dots and validates the scheme; private
ranges are parsed via a new Ipv4 util rather than substring-matched;
comparisons are case-insensitive per RFC 4343; NormalizedRelayUrl.isOnion()
delegates instead of keeping a second, weaker copy of the test.
No performance regression: the old form ran six full-string scans, the new one
bounds its work to the authority and rejects a DNS host from an IP parse on one
character. Ipv6.isLiteral gained a two-colon gate so the schemeless host:port
case answers without allocating the parser's buffer.
Ipv6 is now pinned by a differential test: 4000 random addresses round-trip
against java.net.InetAddress in both directions, and the canonical form is
asserted equal to OkHttp's host for the same address, so the relay identity the
app stores provably matches the host it dials.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQr8CDsznzCRUeB5tS8VYk
Closes the four gaps the previous commit characterized for relays on an
Yggdrasil overlay, where every relay is an IPv6 literal in 0200::/7 served
over plain ws:// (no DNS, no CA-issuable certificate).
New quartz/utils/Ipv6.kt: pure-Kotlin literal parsing, RFC 5952 canonical
formatting and range classification. No java.net, so it works on every KMP
target.
- Canonicalize the bracketed host in RelayUrlNormalizer.norm(). RFC 4291 lets
one address be spelled many ways and the RFC 3986 pass only folded hex case,
so two spellings survived as two NormalizedRelayUrl values for one host —
and that value keys the connection pool, the relay-list sets, the NIP-11
cache and the per-relay stats, so the app dialed one relay twice. The
canonical form matches what OkHttp renders when it dials; the tests assert
that agreement differentially. Relay lists rehydrate through normalizeOrNull,
so stored entries fold on load and no migration is needed.
- Add isOverlayNetwork() for 0200::/7 and default those relays to ws://:
nothing can issue a certificate for the range, so wss:// could only fail its
handshake, and the overlay already encrypts end to end.
- Teach isLocalHost() the IPv6 twins of the literals it already knew — ::1,
fc00::/7 and fe80::/10 — so a relay on one skips TLS and Tor and stays out of
published relay lists, as its IPv4 equivalent already did.
- Never route an overlay relay through Tor: the range is unroutable there, so
proxying guaranteed failure rather than privacy. TorRelayEvaluation covers
both the Android and desktop relay paths; RoleBasedHttpClientBuilder covers
non-relay HTTP.
- Bracket a bare IPv6 literal automatically (what yggdrasilctl getSelf prints),
but only when the whole string parses as an address, so host:port and
addressable pointers still fall through. RelayUrlEditField now shows an error
instead of no-opping, fixing the silent Add button for all invalid input.
Mesh relays are still published in NIP-65 and offered by the outbox model; the
plan doc explains why that is left as a maintainer's call, and records that no
live socket test was possible here (the container has no IPv6 stack).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQr8CDsznzCRUeB5tS8VYk
Assesses how the app fares when relays live on an Yggdrasil overlay, where
every relay is a bracketed IPv6 literal in 0200::/7 served over plain ws://
(no DNS, no CA-issuable certificate).
The happy path works: a hand-typed ws://[...]:port normalizes, survives the
RFC 3986 pass and is dialed by OkHttp; nothing in the stack is IPv4-only and
cleartext is already permitted globally.
Four gaps are pinned by the new characterization tests:
1. RelayUrlNormalizer folds hex case but not zero-compression, so two legal
spellings of one address yield two NormalizedRelayUrl values while OkHttp
collapses them to one host — duplicate sockets, REQs and stat entries.
2. isLocalHost() does not know 0200::/7, so a schemeless literal defaults to
wss:// and can only fail its TLS handshake.
3. An unbracketed literal (what yggdrasilctl getSelf prints) is rejected, and
RelayUrlEditField.submitRelay has no else branch — the Add button silently
does nothing.
4. TorRelayEvaluation classifies mesh relays as "new", so with Tor on they are
dialed through the SOCKS proxy, which cannot route 0200::/7.
No behavior is changed. quartz/plans/2026-08-04-yggdrasil-ipv6-relays.md records
the full assessment, the NIP-65/outbox propagation consequences of publishing a
key-derived mesh address, and what could not be verified here (the analysis
container has no IPv6 stack, so nothing below the socket was exercised).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQr8CDsznzCRUeB5tS8VYk
Merges nostr proposal 259a0bb1 into main:
- fix(desktop): auto-enable master notif switch when OS permission already granted
Follow-up to e9475dd0, which only flipped the master notification switch on
the NotRequested -> Granted path. Adds NotificationSettings.wasExplicitlyDisabled()
(backed by a new java.util.prefs "explicitly_disabled" key) so the Settings
screen can tell "off because it defaults off on first launch" from "off
because the user turned it off", and a LaunchedEffect that auto-enables the
switch in the former case when the OS permission is Granted or NotApplicable.
Adds a "Turn on desktop notifications" recovery button for the deliberate
opt-out path.
Note: on Windows/Linux permissionState is NotApplicable from startup, so the
master switch now auto-enables the first time the user opens Notification
Settings, overriding the off-by-default first-launch state.
Verified before merge: :commons:jvmTest (4 new hermetic tests) and
:desktopApp:compileKotlin both green on top of current main.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merges nostr proposal 1d98285c into main:
- ci: publish linux-arm64 desktop, amy, and geode release assets
Adds ubuntu-24.04-arm legs to the build-desktop, build-cli and build-geode
release matrices so aarch64 Linux users get .deb/.rpm/.AppImage/.flatpak/
.tar.gz for the desktop app plus amy and geode bundles. Parametrizes the
appimagetool fetch, the portable archive names and the Flatpak bundle name
by arch, computes the AppImage multiarch lib path from uname -m at launch,
and extends the desktop release-deb smoke test to arm64.
Verified before merge: the appimagetool 1.9.0 aarch64 SHA256 pin matches
the upstream release, and secp256k1-kmp-jni-jvm-linux ships a
linux-aarch64 libsecp256k1-jni.so so signing works on arm64.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A kind-0, limit-1 REQ works everywhere: purpose relays (purplepag.es)
reject kind-less filters outright, and practically every relay stores
some profile. Verified against production — purplepag.es's read side now
measures instead of going unobserved. Pass a different kinds list to
probe a specific shelf, or null for a kind-less query on relays known to
allow one.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9sSyh1QLJD3PZ18tVPPVK
Verified the new probe surface end-to-end against production relays
(probeFlow streaming, readWriteCheck, signed 30166 templates). One
compatibility finding: purpose relays like purplepag.es reject any REQ
that names no kind ('blocked: filters must specify at least one kind'),
leaving their read side unobserved. readTestFilter/readWriteCheck now
take an optional kinds list for those; the default stays kind-less
because naming kinds also narrows the query on every other relay.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9sSyh1QLJD3PZ18tVPPVK
Audit findings across the branch, each verified with a failing test or
measurement before the fix:
- publishAndCollectResults counted an OK from a relay OUTSIDE relayList
(same event id — a probe-wave straggler, or any republish of the same
event to a different relay set) toward its confirmation window, ending
the wait loop early and misreporting still-pending listed relays as
NO_RESPONSE. The OK branch now carries the same relayList guard the
onCannotConnect/onDisconnected branches always had. Regression test
proves the failure without the guard. readWriteCheck additionally
varies the probe event content per wave so wave N's confirmation window
can never match wave N-1's event id at all.
- RelayUrlNormalizer.fix() called trimEnd('%','2','0') unconditionally,
allocating a full string copy for ANY url merely ending in '%', '2' or
'0' — which includes every relay port ending in zero (wss://host:3030).
Now gated on endsWith("%20"), keeping the hot path allocation-free;
semantics unchanged (test pins both the trim and the untouched-port
cases).
- amy relay probe --file: unreadable file is now a clean bad_args error
instead of a stack trace, and skipped onion urls are counted and
reported (file_onion_skipped) instead of vanishing from the tally.
- probeFlow KDoc now states that a slow collector eats into the current
wave's absolute deadline (answers are still recorded; silent relays get
less listening time), not just that it delays the next wave.
Verified non-issues: androidx.collection LruCache is internally locked
(safe for CachedNip11Fetcher/normalizer concurrency); probeWave's
per-terminal emission cannot lose or double-emit verdicts (remaining-set
guard, data maps read at emission time); existing publish callers all
benefit from the OK guard rather than depending on the old behavior.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9sSyh1QLJD3PZ18tVPPVK
`needsToResendRequest(List, List)` used a non-local `return` inside
`forEachIndexed`, so the loop always returned on iteration 0 and only
`filters[0]` was ever compared. A subscription whose first filter happened
to be unchanged reported "no resend needed" however much the rest had
changed, leaving the relay serving a stale filter set and the app silently
missing events. Only the size check offered any protection, so the bug was
invisible whenever the filter count stayed constant.
Replaces the loop with an indexed scan over all filters, which also drops
the lambda allocation and matches the hot-path style in this package.
Adds FiltersChangedTest. 3 of its 9 cases fail on the unfixed code — all of
them changes beyond index 0 — while the other 6 pass both before and after,
pinning the blast radius to exactly the buggy behaviour. Coverage includes
the deliberate `since`-moves-forward exemption, which must not trigger a
resend on any index.
Note for reviewers: PoolRequests.kt:490 and :528 use this inverted as a
"same as last" refusal check, so those become stricter — filter sets that
differ only beyond index 0 were previously treated as identical and will
now correctly be treated as changed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
RelayProber.readWriteCheck(relays, signer) is the deeper check pair for
relays already proven live (warm sockets from a probe that just ran):
- READ: a real limit-1 REQ the relay must query its store for, timed
REQ→first answer (honest rtt-read on an open socket).
- WRITE: one ephemeral RelayProbeWriteTest event signed by the monitor
key, timed publish→OK (honest rtt-write). An OK false is a measured
policy answer, kept with its NIP-01 machine-readable reason; only
silence leaves the write side unobserved (writeAccepted = null).
publishAndCollectResults now stamps each OK with its elapsedMs (a
rejection is still a round trip; -1 when the relay never answered), so
any caller gets write latency for free.
toDiscoveryEventTemplate(readWrite = ...) folds the pair into the 30166
template: rtt-read/rtt-write when measured, R auth / R pow when the
write was refused with auth-required:/pow:. NIP-11-derived tags (N
supported NIPs, k kinds, T type) are deliberately NOT emitted — those
are relay self-claims, and publishing them under a monitor signature
without per-NIP compliance tests would launder claims into
measurements. Per-NIP/per-kind compliance suites can come later as
opt-in checks; open/read/write is the default surface.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9sSyh1QLJD3PZ18tVPPVK
RelayProber.probe()/probeFlow() take a filters option choosing the check:
LIVENESS_FILTERS (default, impossible-id REQ — EOSE proves liveness with no
payload) or readTestFilter(limit = 1) — a REQ the relay must actually work
for, querying and streaming real events, making Verdict.rttEoseMs a genuine
read test.
RelayProbeWriteTest.build() creates the write-check event: ephemeral kind
20166 (never stored by compliant relays) carrying a NIP-40 expiration tag
60s out as belt-and-braces for relays that store unknown ephemeral kinds.
Publish it under the monitor key, time the OK for rtt-write, map rejection
prefixes to R requirement tags — an OK false still proves the write path.
Nip11Fetcher is the missing fetch seam for relay information documents,
mirroring Nip05Fetcher: the interface lives in commonMain,
OkHttpNip11Fetcher (jvmAndroid) does the Accept: application/nostr+json
GET, and CachedNip11Fetcher wraps any implementation with a TTL cache —
successes trusted for a day, failures remembered for five minutes so a
census doesn't hammer hosts that just refused.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9sSyh1QLJD3PZ18tVPPVK
RelayProber.probeFlow(urls) is a cold Flow that emits each relay's Verdict
the moment the relay resolves (EOSE, CLOSED or connect failure) instead of
at the end of the whole census; only silent relays wait for their wave's
deadline. probeWave now resolves verdicts per-terminal, so the batch
probe() shares the same path.
Verdict.toDiscoveryEventTemplate() renders a verdict as an UNSIGNED
kind:30166 template (d = normalized url, n network type, rtt-open when
reachable, R auth when the probe hit a NIP-42 auth-required CLOSED) so an
external consumer signs with its own monitor key:
prober.probeFlow(urls).map { it.toDiscoveryEventTemplate() }
.collect { publish(signer.sign(it)) }
rtt-eose is deliberately never published as rtt-read: it is measured from
the wave start (dial + TLS + queueing + read), and aggregators rank on
rtt values. The RelayObserver/RelayMonitor path supplies honest
rtt-read/rtt-write from real traffic.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9sSyh1QLJD3PZ18tVPPVK
Feeds a file of raw candidate urls (one per line) through the same
RelayUrlNormalizer the app uses, then probes the surviving clearnet set
alongside the store's known universe. Rejected and onion counts are
reported (file_urls/file_normalized/file_rejected in the JSON output),
and results land in the NIP-66 kind:30166 reachability cache keyed by
the normalized url as d-tag, as usual.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9sSyh1QLJD3PZ18tVPPVK
Validated against a 45k-entry corpus of relay-url hints exported from real
events (317k tag occurrences). The normalizer was converting ~30k distinct
https:// urls with paths (Mastodon/bridge actor urls from proxy tags, web
pages, images) into wss:// addresses that can never answer, wasting
connection attempts and relay-pool slots.
- http(s) → ws(s) scheme swap now only applies to bare hosts
(host[:port] plus optional trailing slash); an http url with a path,
query or fragment is a web resource, not a mistyped relay.
- Authority validation for all schemes: rejects empty hosts, userinfo
(@), percent-encoding and commas in the host, and paths that start
with // (the signature of a second pasted url, e.g. wss://https//host).
- Interior whitespace and backslashes reject the whole string (multiple
urls or prose in one field).
- Zero-width characters (U+200B..D, U+2060, BOM) are stripped instead of
corrupting the parse (wss://\u200Bnos.lol previously normalized to the
scheme-less //nos.lol/).
- Schemeless candidates must look like host[:port] (single colon, numeric
port), rejecting addressable pointers (31990:pubkey:dtag) and bare
scheme leftovers (wss:) before the expensive RFC 3986 parse.
- Protocol-relative //host/ inputs normalize as wss:// instead of
resolving to https://.
- normalizeOrNull now double-checks the parser output still starts with
ws(s):// and rejects otherwise.
Corpus impact: 30,014 garbage urls (30,333 events) now rejected, 0 real
relays lost (all 15,162 kept urls normalize byte-identically), 6 broken
outputs fixed. fix() itself stays allocation-free on the happy path
(~357ns vs ~318ns per call on the garbage-heavy corpus).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9sSyh1QLJD3PZ18tVPPVK
quartz:
- SQLiteEventStore: classify per-row savepoint errors — policy refusals
(blocked:/constraint/not allowed) stay Rejected, everything else is now
Failed, so disk-full no longer masquerades as 2M duplicate rejections
- IEventStore.batchInsert default: rethrow CancellationException and map
unknown throws to Failed (re-offering a duplicate is idempotent;
dropping a good event on a transient store error is not)
- IngestQueue: rethrow CancellationException instead of stamping a
cancelled batch Failed and continuing
- HostStrikes: make the eviction verdict exactly-once under concurrency
(deadHosts.add is the atomic gate) and re-check produced before
publishing
- SyncCoverage: bound the identity fingerprint cache (a caller minting
fresh Filter instances per cycle could grow it forever); legs() gains a
floor parameter so a complete band re-opens its older span when the
caller's window deepens; coveringWindow no longer treats a fully
covered relay as needing the whole filter
- PagingWindowProgress: accept single-second windows (a band's re-read
edge leg is exactly that shape)
geode:
- MirrorWorker: cap reconciledThrough at the leg's own ceiling — the
older leg of a resumed catch-up no longer stamps the band complete
through 'now' before the newer leg has run (silent event loss for up
to fullResyncSeconds if that leg failed)
- MirrorWorker: run negentropy and the paged fallback by hand instead of
negentropySyncOrFetch: drops the O(delivered-ids) dedup set from the
mirror path, and a fallback resets the observed span so a band never
claims interior ranges only a half-finished reconcile scattered over
- MirrorWorker: clamp a paged band's ceiling to the snapshot instant so
one future-dated event cannot suppress the next boot's newer leg
- MirrorWorker.close(): join the workers (bounded) so the final coverage
flush carries the last records
- Main: gate the coverage file on the store actually being persistent —
database.file with in_memory=true (the default) persisted bands over a
volatile store, and the next boot skipped the backfill over an empty
database; honor --db overrides
- SyncCoverageFile: request ATOMIC_MOVE explicitly; fix the restore/dirty
comment
- Import summary now prints the failed count; document
mirror_sync_state_file in config.example.toml
Three semantics rules each existed as multiple independent copies:
- Event.owner() (gift-wrap recipient controls the wrap, else the
author — NIP-09/62 authority) was derived inline in
EventIndexesModule and again in EventStoreProjection.ownerOf.
- The NIP-01 replaceable tiebreak (newest created_at, ties to the
lexically smallest id) lived in EventStoreProjection.supersedes,
in SQL, and downstream.
- "Indexable tag name" was spelled `length == 1` in four places,
which admits "5" and "#" — names the NIP-01 #x filter space
(single a-zA-Z letters) cannot address, letting stores disagree
about which tags filters reach. isIndexableTagName encodes the
NIP-01 rule; converging FilterIndex and the SQLite
IndexingStrategy on it deliberately tightens single-char
non-letter tag names out of the index.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MfwV3xgMSmfxy16ujGPxGW
An all-dash search token ("--") stripped to an empty exclusion that
toSearchString/stripExtensions round-tripped into a REQUIRED "-" term
reaching SQLite FTS; it is now dropped at parse. IngestQueue's
batchInsert fallback was the one site still hand-writing "insert
failed" (unprefixed) while every other path emits
RejectionReason.INSERT_FAILED. RejectionReason no longer duplicates
the NIP-01 prefixes MachineReadablePrefix already owns, and the
expiration trigger now rejects with the same words as the Kotlin
pre-check instead of its own spelling. Tests pin the "--" drop,
consecutive quoted spans, text after a closing quote, the extractor's
fallback tier, blank-content normalization, and the
unparseable-buzz-content hashtag seam; extractor KDoc now states
where the trimmed/non-empty and never-empty-Profile guarantees
actually live.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MfwV3xgMSmfxy16ujGPxGW
SearchableEvent.indexableContent() flattens a kind's searchable text
into one blob, so a weighted full-text backend (title above summary
above body) had to re-derive the decomposition itself — and drift
every time a kind's parsing changed here. SearchFieldExtractor now
lives beside the kinds it decomposes: title-like accessors primary,
summary/description secondary, body tertiary, kind-0-shaped metadata
in profile roles, with an indexableContent() fallback so every
searchable kind, current or future, is covered.
IndexableFields is a sealed shape — Profile or Tiered — so a kind
cannot mix identity fields with content tiers, and each shape
declares its own website role (a profile's homepage; a content kind's
affiliation URLs). Multi-valued roles are carried UNJOINED, as lists:
hashtag and location tags ride raw beside the tiers (filled by the
one tiers() funnel every content branch uses, so no branch can forget
them and profile shapes never see them), and separator or weighting
choices — hashtags at summary weight, "\n" vs " ", arrays vs joined
columns — belong to the backend, not the library. Empty extractions
always normalize to None.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MfwV3xgMSmfxy16ujGPxGW
Renames from review: SyncBands -> SyncCoverage ("sync" reads negentropy-ish
in quartz, and coverage is the role — the bands are the records), and
PagingProgress moves into relay.client.paging as PagingWindowProgress,
beside RelayLoadingCursors and RelayPagingProgress, with its docs swept from
"walk" dialect to quartz's pagination vocabulary and cross-references
delineating the three: cursors are in-memory positions for demand-driven UI
paging, the window progress is fraction/ETA for a bulk pagination over a
known window, coverage is persistent intervals that license skipping work.
geode adopts both halves of the new contract. MirrorWorker counts
InsertOutcome.Failed in its own `failed` counter instead of folding it into
`rejected`, and the down catch-up gains resume memory: SyncCoverageFile
persists SyncCoverage next to the event database (admin state-file
convention, temp-file + atomic move, daemon flush), and runCatchUpDown asks
only for the legs outside the covered band. Bands are keyed on the stable
scoped filter — never the boot window, whose since/until change every start
— and clamped to the window, which only slides forward, so an old band can
never license skipping a range an earlier boot could not ask about. A clean
reconcile records completeness through its snapshot instant; a paged
fallback earns only the span it saw. For an upstream without NIP-77 this
turns the every-boot full re-download of the backfill window into a
resumed walk.
Off unless wired: MirrorWorker's coverage parameter defaults to null and
in-memory stores keep no state file, so existing tests and setups are
unchanged. Full :quartz:jvmTest and :geode:test pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y4Pi9YYMhdzTFxRiV2jF9R
Bisecting existed to reconstruct per-event attribution after a store threw a
batch-wide exception. Better to never lose the attribution: batchInsert's
contract now requires per-row isolation, with a third outcome telling whose
fault a miss was. Rejected is the EVENT's fault (duplicate, expired, invalid,
blocked) and is final; Failed is the STORE's fault (schema drift, a failed
feed, a resource error) — the event was good, it is lost unless re-offered,
and a rising Failed count means the store is broken rather than that
upstreams send junk. Throwing is reserved for failures with no per-event
answer (engine unreachable, transaction never started), readable as "nothing
in this batch was written".
Consumers updated: RelaySession maps Failed to OK false with NIP-01's
"error:" prefix; IngestQueue converts a thrown batch and a missing outcome to
Failed instead of Rejected; NdjsonImportExport counts failed apart from
rejected; geode's MirrorWorker logs store failures at warn instead of
folding them into debug-level rejections. BisectingInsert and its test are
removed — with attribution guaranteed by the contract, retry-by-splitting
has nothing left to do.
Full :quartz:jvmTest passes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y4Pi9YYMhdzTFxRiV2jF9R
NIP-50 search strings in the wild carry Google-style syntax the
extension tokenizer could not see: "exact phrase" requirements,
-"phrase" and -word exclusions. SearchQuery now lifts quoted spans
BEFORE the extension pass — the order is load-bearing: the extension
pass is quote-blind, so a span ending in an extension-shaped token
would lose its closing quote, and lifting first also lets quotes
protect extension-shaped tokens ("include:spam" is a phrase, not an
extension). toSearchString() and stripExtensions() reassemble the
full grammar; existing parses are unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MfwV3xgMSmfxy16ujGPxGW
Every store spelled its rejection reasons inline — the expired-event
string was duplicated four times across ObservableEventStore and
SQLiteEventStore. RejectionReason now carries the NIP-01 OK
machine-readable prefixes plus the standard store reasons, so
InsertOutcome.Rejected tallies and OK-frame building see one
vocabulary no matter which store produced the outcome.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MfwV3xgMSmfxy16ujGPxGW
Four pieces extracted from a production Nostr mirror (NosFabrica/vespa-relay),
generalized to quartz's multiplatform primitives, with their test suites:
- nip01Core.store.insertBisecting: batchInsert fails as a unit, so one bad
event costs its whole batch (999 good events per bad one at a 1000-event
batch). Bisecting isolates the offender in ~2*log2(n) extra writes, and a
fixed write budget keeps a store-wide failure (full disk, dead engine) from
turning one failed write into ~2n.
- accessories.SyncBands: resume memory for fetchAllPages. Remembers the
created_at band covered per (relay, filter) and asks only for the legs
outside it, with inclusive edges so a page boundary cannot strand a run of
same-second events. A finished negentropy reconcile records completeness
through its start instant; a periodic full re-walk keeps stale claims from
narrowing forever. Persistence is the caller's, via export/restore and an
onChange hook.
- accessories.PagingProgress: progress for paged walks measured on the time
axis, the only axis whose end is known in advance - count-based percentages
degenerate to downloaded/downloaded = 100%. Needs no COUNT support.
- nip66RelayMonitor.reachability.HostStrikes: per-authority strike counting
for outbox-scale fan-outs, where a filtering relay mints one url per user
and per-url counters never converge. Ever-delivered overrides eviction in
both race orders, and eviction surfaces exactly once for publishing.
reachability.Unreachability (jvmAndroid): which failures may be published
as a signed NIP-66 unreachable record - connection-level only, so a relay
that answered the handshake and hung up mid-page is never libelled, and a
caller's own bug is never the relay's fault.
57 tests pass on the JVM target; the common code uses quartz's ConcurrentMap,
ConcurrentSet and TimeUtils only.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y4Pi9YYMhdzTFxRiV2jF9R
Completes the store-implementer skill set requested by the vespa-eventstore
consumer: the NIP-85 trust-assertion model (kind map, tag vocabulary, value
semantics, authorization conventions) and the NIP-50 indexing surface (the
SearchableEvent contract plus an exhaustive kind -> indexableContent table
external search engines can diff at version bumps).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADBfjWhsXyHZb7ea2eeBhJ
Documents the store's observable behavior as named rules (STORE-F/W/D/C/S/N)
so external IEventStore implementations can review pin bumps and annotate
divergences against a stated contract instead of reverse-engineering
QueryBuilder. Requested by the vespa-eventstore consumer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADBfjWhsXyHZb7ea2eeBhJ
Amethyst v1.13.1 (and every prior release) shipped only linux-x64 desktop
binaries — .deb, .rpm, .AppImage, .flatpak, .tar.gz. Same for the amy
CLI and geode relay. Users on aarch64 hardware (Pinebook, Ampere
Altra, Raspberry Pi 4/5, AWS Graviton, arm64 servers, arm64 Chromebooks
running crostini, etc.) can't install any of them.
This teaches the release matrix about arm64:
- Add `ubuntu-24.04-arm` legs to build-desktop, build-cli, and
build-geode. This is a standard, free public-repo GitHub-hosted
runner (4 CPU / 16 GB / 14 GB SSD / arm64) since early 2025. No
cross-compilation: jpackage / jlink / Compose Multiplatform 1.11
all produce host-native artifacts.
- Fetch the matching `appimagetool-<arch>.AppImage` from the same
1.9.0 release with an arch-specific SHA256 pin. `APPIMAGETOOL_URL`
becomes `APPIMAGETOOL_VERSION` + per-arch SHA256 env vars.
- Parametrize the portable tarball/zip filename by `matrix.arch`
(`amethyst-desktop-<ver>-linux-arm64.tar.gz` is now produced).
- Parametrize the Flatpak bundle filename and rewrite the manifest's
`GST_PLUGIN_SYSTEM_PATH` from `x86_64-linux-gnu` to
`aarch64-linux-gnu` on the arm64 leg. The Flathub-submission manifest
(`desktopApp/packaging/flatpak/flathub/`) still gates on
`only-arches: x86_64` — flipping that to include aarch64 is a
follow-up once a Flathub aarch64 build has been validated end-to-end.
- Make the `createReleaseAppImage` gradle task pick its host arch from
`System.getProperty("os.arch")` (amd64/x86_64 → `x86_64`, aarch64/
arm64 → `aarch64`). Same task, same command, drives both legs.
- Fix `desktopApp/packaging/appimage/AppRun` to compute the multiarch
library path from `uname -m` at launch time instead of hard-coding
`x86_64-linux-gnu`. One script works in both AppImages on the target
machine.
- Extend the desktop smoke test to run the release .deb build + launch
probe on `ubuntu-24.04-arm` too, so arch-specific ProGuard/jlink
breakage (missing native lib, arch-specific reflection root) is
caught at PR time.
- Update BUILDING.md and scripts/asset-name.sh docs with the new
arm64 asset names.
Follow-up assets published for the next tag push (v1.13.2+):
- amethyst-desktop-<ver>-linux-arm64.{deb,rpm,AppImage,flatpak,tar.gz}
- amy-<ver>-linux-arm64.{deb,rpm,tar.gz}
- geode-<ver>-linux-arm64.{deb,rpm,tar.gz}
Verification (local, before submitting):
- `python3 -c 'import yaml; yaml.safe_load(open(".github/workflows/create-release.yml"))'` — parses clean
- `bash -n scripts/asset-name.sh desktopApp/packaging/appimage/AppRun` — parses clean
- `actionlint` — reports only pre-existing shellcheck style hints; no new errors
- Confirmed `linuxdeploy-aarch64.AppImage` and
`appimagetool-aarch64.AppImage` exist under the same pinned release
tags used for x86_64; SHA256 recorded from a fresh download.
Not addressed (out of scope for this PR):
- Homebrew / winget bump workflows (`bump-homebrew*.yml`,
`bump-winget.yml`) — those consume the assets by name; the new arm64
filenames don't change any x86_64 name they already reference.
- Android arm64 continues to ship as before (already had it).
A production ANR on a Pixel 8 (Amethyst 1.13.1, anr_2026-08-03-12-55-26-256)
showed the app burning 596% CPU — 6 of 9 cores — with the main thread stuck in
WaitingForGcToComplete. 37 of 52 runnable DefaultDispatcher workers sat at ONE
program point inside PoolRequests.onIncomingMessage and 12 more at one point in
syncState, all state=R, while the single thread actually holding the lock was
itself parked in GC.
Root cause: RequestSubscriptionState.withLock was a raw busy-wait
(`while (lock.exchange(true)) { while (lock.load()) {} }`) with no yield or
backoff, and being `inline` it disappeared into its callers' frames. The lock is
per subId, but one subId spans every relay it runs on — 191 live sockets on that
device — so dozens of relay-dispatch threads piled onto a single AtomicBoolean.
Spinning is only correct when the holder cannot be descheduled; on Android it
always can.
The fix stripes the lock per (subId, relay) rather than making waiting cheaper.
All 11 withLock bodies in PoolRequests are already scoped to exactly one relay,
and every field of RequestSubscriptionState is keyed by relay, so the sharing was
purely an artifact of mutableMapOf not being thread-safe. State moves into a
ConcurrentMap<T, RelayState>; locks live in a fixed 32-entry stripe array that is
never mutated, so lock identity stays stable — if locks lived inside the map
values, a thread holding one while another dropped and re-created that entry
would leave both inside the critical section excluding nothing.
A suspending Mutex was measured and rejected: it needs 262 method overrides and
110 call sites to become suspend, and ran at 0.35-0.63x the current throughput.
Measured (LockDesignComparisonBenchmark, 191 relays / 64 dispatcher threads):
striped vs per-sub lock 1.5-2.8x throughput, bystander p50 halved
On device (SM-T220, playBenchmark, same account, n=3 per design):
DefaultDispatcher CPU -35% mean / -30% median vs the spin lock,
with non-overlapping ranges; GC -18%
Plus 10 min of driven UI stress (feed, profiles, chat, notifications,
communities): no ANRs, no crashes, thread pools stable.
Also here:
- PlatformLock: new expect/actual parking lock (ReentrantLock on jvmAndroid,
NSRecursiveLock on Apple, spin only on linuxX64 which is a CI target). quartz
cannot use commons' equivalent KmpLock because commons depends on quartz.
- LiveNegentropyIndex had the identical busy-wait with a full list SORT inside
the critical section; switched to PlatformLock.
- ConcurrentMap.remove (+ tests), with a caution that a removable value must not
own a lock callers acquire.
- SpinLockConvoyBenchmark: regression guard asserting contended waiters PARK
rather than spin (fails-before / passes-after). Pure benchmarks are gated
behind -PprodRelayBench=1, so CI cost is 0.3s rather than 51.5s.
Analysis and measurements: quartz/plans/2026-08-03-poolrequests-lock-contention.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>