Commit Graph
2411 Commits
Author SHA1 Message Date
Claude b0e3594eaa feat(concord): add rekey distribution (CORD-06)
Non-ratcheted async key rotation to remove members from a channel or (root
scope) the whole community, pinned to Concord v2 (Armada rekey.ts):

- RekeyPayload: the 72-byte scope_id||epoch_be8||new_key blob codec
- RekeyBlob: per-recipient {locator, wrapped} entry
- ConcordRekey: blobFor (locator = recipient pseudonym; wrapped = base64 payload
  NIP-44-encrypted under the rotator<->recipient pairwise key), kind-3303 rumor
  tags (scope/newepoch/prevepoch/prevcommit/chunk) and content codec, and
  findNewKey (recipient computes their locator, matches, decrypts, verifies
  scope+epoch) with absence == removal

Test proves remaining members recover the rotated key while a removed member
finds no matching blob, and that the locator is epoch-bound. Green on
:quartz:jvmTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-09 22:20:51 +00:00
Claude 06fa80da65 feat(concord): add invite-link codec and bundle key (CORD-05)
- ConcordKeyDerivation.inviteBundleKey: derives the bundle decryption key from a
  link's 16-byte unlock token via hkdf32(token, "concord/invite-key")
- InviteRelayDictionary: the v4 stock relay set + id<->url mapping
- ConcordInviteLink: encode/decode the {base}/invite/{naddr}#{fragment} link and
  the [version=4][flags][relays?][token:16] fragment (stock-set flag, dictionary
  ids, wss:// host and full-url relay entries), rejecting non-v4 versions; builds
  the naddr (33301, link_signer, d="") and parses it back

Tests cover stock/dictionary/literal/full-url relay round-trips, wrong-version
rejection, full URL round-trip through naddr, and token-bound bundle key
derivation. Green on :quartz:jvmTest. Pinned to Concord v2 (Armada) constants.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-09 22:06:34 +00:00
Claude 982cbb6a21 feat(concord): add community-state fold and guestbook plane
- ChannelEntity/MetadataEntity content DTOs (CORD-02/03)
- ConcordCommunityState.fold: folds control editions + known owner into the live
  community view — metadata, non-deleted channels, live roles, the owner-rooted
  AuthorityResolver, and a dissolved flag from the tombstone
- Guestbook: self-signed join/leave (kind 3306, with invite attribution) and
  authorized kick (kind 3309) rumor builders + parsers; off-consensus membership
  motion

Tests cover metadata/channel/role folding with deleted-channel exclusion,
authority wiring, the dissolution tombstone, and join/leave/kick round-trips.
Green on :quartz:jvmTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-09 22:02:54 +00:00
Claude d51d8e6d71 feat(concord): add channel key derivation and chat message binding
CORD-03 Chat Plane vertical slice tying crypto + envelope together:

- ConcordChannelKeys: public (community_root) and private (channel_key) channel
  key derivation, both via group_key with channel_id folded in so each channel
  has a distinct, epoch-rotating address
- ChannelChat: channel/epoch binding tags, a kind-9 message rumor builder, and
  isBoundTo validation so an event can't be replayed across channels/epochs

End-to-end test proves two members holding the same community_root independently
derive the identical public channel plane and one reads the other's message with
no key distribution, non-members can't derive the plane, cross-channel/epoch
replay is rejected, and epoch rotation rotates the address. Green on
:quartz:jvmTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-09 21:59:05 +00:00
Claude 71db306f8d feat(concord): add owner-rooted authority resolver
Implement CORD-04 authority resolution over a folded Control Plane:

- ControlEntities: Role and Grant content DTOs (kotlinx.serialization, lenient +
  extensible) and a Banlist array parser; ConcordJson facility
- AuthorityResolver: builds roster state from the entity heads + known owner and
  answers rank (lower = higher; owner = 0), effectivePermissions (union of a
  member's roles), isBanned, hasPermission, and canActOn (holds the bit AND
  strictly outranks the target — equal cannot act on equal; owner unremovable)

Grants are validated by an owner-rooted fixpoint: a Grant is honored only when
its signer already outranks every assigned Role and holds MANAGE_ROLES, so the
roster grows strictly outward from the owner and self-referential cycles never
bootstrap. Deleted/position-0 roles and banned members are dropped. Banlists heal
to their union.

Tests cover owner-rooted ranks/permissions, fixpoint order-independence,
unauthorized/insufficient-rank grant rejection, ban vanishing, and invalid-role
dropping. Green on :quartz:jvmTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-09 21:56:09 +00:00
Claude c865d0f4eb feat(concord): add control edition parsing and chain folding
- ControlEdition: parses a verified kind-3308 rumor's vsk/eid/ev/ep/vac tags into
  a typed edition, computes its domain-separated edition hash, and rejects
  malformed editions (bad kind, missing eid/ev, unknown vsk, bad hex)
- AuthorityCitation: the vac Grant pin an actor claims rank under
- EditionFold: folds editions into each entity's current head — genesis
  anchoring, intact-chain / no-downgrade advancement, deterministic lower-rumor-id
  tie-break for convergence (authority-weighted tie-break layered in the resolver)

Tests cover tag parsing + hash, malformed rejection, genesis prev handling,
order-independent chain walk, downgrade/broken-chain refusal, tie-break, and the
hold-without-genesis case. Green on :quartz:jvmTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-09 21:51:35 +00:00
Claude 828792eb40 feat(concord): add kind registry, control entity kinds, permission bitfield
- ConcordKinds: every Concord event kind (envelope, chat, guestbook, control,
  rekey, bookkeeping), pinned to Concord v2 (Armada kinds.ts)
- ControlEntityKind: the kind-3308 `vsk` sub-kinds (metadata=0..dissolved=10)
  with wire<->enum mapping
- ConcordPermissions: u64 permission bitfield with frozen bit positions, union
  (effective = OR of a member's roles), and decimal-string wire codec that
  preserves the high bits (no floating-point corruption)

Tests cover frozen bit positions, union, decimal round-trip incl. bit 63, blank
and garbage handling, and vsk mapping. Green on :quartz:jvmTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-09 21:46:40 +00:00
Claude 30b6d70cfc feat(concord): add stream envelope (wrap/seal/rumor) layer
Implement the Concord CORD-01 stream envelope in quartz `concord/envelope`:
the inverted NIP-59 three-layer wrap -> seal -> rumor that carries every plane's
traffic. The outer kind-1059/21059 wrap is signed by the shared stream key and
its content is NIP-44-encrypted under the plane's self-ECDH conversation key,
with an ephemeral p tag, so relays never see plaintext.

- ConcordStreamEnvelope.seal: 20014 plaintext (verbatim rumor JSON, for the
  Control Plane) or 20013 encrypted seal, signed by the real author
- wrapSeal/wrap: sign+encrypt the wrap at a GroupKey plane address
- open/openOrNull: verify wrap author == stream address + wrap sig, decrypt seal,
  verify seal sig, decrypt/parse rumor, enforce rumor.pubkey == seal.pubkey and
  rumor.id == NIP-01 hash
- OpenedStreamEvent: verified rumor + seal kind + author

Reuses RumorAssembler, NostrSigner, NostrSignerSync and Nip44v2. Round-trip
tests cover plaintext/encrypted seals, ephemeral wraps, non-member rejection
(wrong epoch/secret), and confirm plaintext never leaks into wrap content.
Green on :quartz:jvmTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-09 21:41:08 +00:00
Claude ade45c3326 feat(concord): add Concord key-derivation crypto foundation
Introduce the quartz `concord/crypto` package implementing the interoperable
key-derivation core of the Concord protocol (encrypted, serverless communities
on Nostr), pinned to the Concord v2 reference client (Soapbox Armada) for wire
compatibility:

- ConcordLabels: frozen HKDF domain-separation labels (CORD-01..07)
- ConcordKeyDerivation: buildInfo layout, hkdf32, scalar-normalizing
  deriveSecretKey, groupKey (plane/channel address + self-ECDH conv key),
  communityId, voice keys, and rekey recipient locator
- GroupKey: plane key result (secret key, x-only address, conversation key)
- EditionHash: domain-separated, length-prefixed edition-chain hash (CORD-04)

Reuses the in-tree Hkdf, Nip44v2 self-ECDH, KeyPair and Secp256k1Instance
primitives. Adds property-based tests (determinism, distinctness across
label/id/epoch/secret, self-ECDH round-trip, genesis-vs-zero-prev chain,
verbatim-content hashing). All green on :quartz:jvmTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-09 21:24:59 +00:00
Vitor PamplonaandGitHub eaf100bd79 Merge pull request #3508 from nrobi144/feat/desktop-dm-reliability
feat(desktop): NIP-17 DM reliability — AUTH banner, strict inbox resolution, relay hints
2026-07-09 08:51:20 -04:00
davotoula 94a966df80 refactor: share the event-header SELECT column list in FullTextSearchModule 2026-07-09 12:32:37 +01:00
nrobi144 a854b38cd8 perf(quartz): cap NIP-17 wrap building at 4 concurrent bunker RPCs
NIP17Factory.createWraps launches all per-recipient seal builds via
mapNotNullAsync, which today runs them fully parallel. Each seal
needs nip44_encrypt + sign — for a NIP-46 (bunker) signer that means
two round-trips per recipient. A 5-recipient group send launches 10
concurrent in-flight requests against the bunker socket, and nsec.app
/ Amber / Keychat typically serialize past ~10 in-flight, so some
requests queue past the 65s timeout and silently fail.

Cap at 4 concurrent when signer is NostrSignerRemote. Local signers
(NostrSignerInternal, NostrSignerSync) bypass the semaphore and stay
fully parallel — no overhead, no behaviour change for nsec users.

The real fix is the batched nip44_get_conversation_keys NIP-46 RPC
(separate spec PR + plan) which collapses N×2 round-trips into ~2.
This commit is the interim throttle until that lands.
2026-07-09 07:03:56 +03:00
nrobi144 ac26a3624f test(quartz): pin relay-hint placement on gift wrap p tag
Three regression tests covering the NIP-17 relay-hint contract just
introduced on GiftWrapEvent.create:

- default (no hint) emits the historical two-element ["p", pubkey]
  shape — guards every existing caller against a wire-format
  regression.
- with-hint emits ["p", pubkey, relay-url] — the canonical NIP-17
  shape with the hint on the public wrap (NOT inside the seal, which
  is the encrypted envelope and would hide routing info).
- null-hint must NOT produce ["p", pubkey, ""] — that would broadcast
  "this user has no canonical inbox" as a metadata leak.
2026-07-09 07:03:55 +03:00
nrobi144 07d4a6d8c4 feat(quartz): plumb optional per-recipient relay hint into NIP-17 gift wraps
Per NIP-17 §Publishing, a gift wrap (kind 1059) MAY carry the
recipient's primary DM inbox relay as a third element of the p tag.
Other clients the recipient runs (or relays acting as inbox routers)
can then locate the wrap without performing their own kind:10050
lookup — handy when the recipient is multi-device and the second
device's 10050 cache is cold.

GiftWrapEvent.create gains an optional `recipientRelayHint:
NormalizedRelayUrl?` parameter that flows into PTag.assemble (which
already accepts a relay hint). NIP17Factory.createWraps and the four
public createMessageNIP17 / createEncryptedFileNIP17 /
createReactionWithinGroup entry points gain a matching
`recipientRelayHints: (HexKey) -> NormalizedRelayUrl?` lambda so
multi-recipient sends can pass per-recipient hints in one shot.

All new parameters default to null / { null }, so every existing
caller compiles unchanged and still emits the historical
two-element ["p", recipientPubKey] shape. Callers that resolve
kind:10050 via the (forthcoming) DmInboxRelayResolver can wire the
result through to populate the hint.

While here, document the existing — but undocumented — invariant
that shared rumor created_at falls out naturally because the
rumor is signed once before the per-recipient mapNotNullAsync loop.
This is what anchors cross-recipient reaction/receipt dedupe.
2026-07-09 07:03:55 +03:00
nrobi144 af76c3a3f3 feat(quartz): expose per-relay AUTH state as a Compose-stable StateFlow
RelayAuthStatus has to stay mutable — it holds LruCaches addressable from
the per-relay OkHttp dispatcher thread, and replacing the whole holder
on every mutation would be wasteful. But its mutability also makes it
useless as a StateFlow value: mutating an entry doesn't change map
identity, so distinct-until-changed downstream swallows the update and
Compose never recomposes.

Add an immutable view alongside: RelayAuthSnapshot (phase +
lastAuthSuccessAt). RelayAuthStatus.snapshot() derives it from the LRU.
RelayAuthenticator publishes a PersistentMap<NormalizedRelayUrl,
RelayAuthSnapshot> via authStateFlow on every mutation (connect,
disconnect, AUTH-submitted, AUTH-OK, AUTH-fail). PersistentMap gives
O(log32 n) updates and a fresh identity per put, so both StateFlow
equality and Compose strong-skipping work.

This is the substrate for downstream consumers — the AUTH approval
banner, the retry-queue wake on authCompleted, the indexer-fan-out gate
— none of which are wired yet. They will read authStateFlow rather than
querying RelayAuthStatus directly.
2026-07-09 07:03:55 +03:00
nrobi144 9d539b22f6 fix(quartz): don't count auth-required: against the publish try cap
NIP-42 AUTH challenges arrive as `auth-required:` OK responses. Today
they accumulate via PoolEventOutboxState.newResponse → Tries.addResponse,
and after three of them the relay is silently dropped from the outbox on
the next newTry — even though RelayAuthenticator is concurrently signing
the AUTH event and the relay would have accepted the original publish
once authenticated.

Carve `auth-required:` out of the failure path: it's a "wait, AUTH in
flight" signal, not a rejection. The existing
RelayAuthenticator.checkAuthResults → client.syncFilters hook re-pumps
the outbox after AUTH-OK, so the original event is retried naturally.

Adds PoolEventOutboxStateTest covering the carve-out plus regressions
for regular rejections, terminal rejections, and success.
2026-07-09 07:03:54 +03:00
Vitor PamplonaandGitHub f11a723518 Merge pull request #3507 from vitorpamplona/claude/eventstore-pubkey-10002-query-hotv4z
feat(quartz): add IEventStore.authorsMissingOutbox() anti-join query
2026-07-08 23:25:58 -04:00
Claude 4f75b9d092 fix(quartz): audit fixes for authorsMissingOutbox — giftwrap carve-out + EXCEPT
Audit of the authorsMissingOutbox anti-join surfaced one correctness bug and
one performance win:

- Bug (semantic): kind-1059 giftwraps store a random one-time key in
  event_headers.pubkey (the real recipient is only a hash), so the query
  returned an unbounded set of ephemeral keys that can never own a 10002 —
  junk for the outbox model this feeds. Both the SQLite path and the generic
  default now exclude kind 1059 from the "authors" set.

- Performance: replaced the DISTINCT + correlated NOT EXISTS scan with an
  index-only EXCEPT (all authors minus 10002 owners). Both sides ride the
  unconditional query_by_kind_pubkey_created covering index — so it does NOT
  depend on the optional pubkey-alone index — and measured ~3x faster
  (44ms vs 137ms at 152k events / 20k authors); the gap widens with author
  count, since the old form paid one seek per distinct author. A loose-index
  skip-scan was rejected: it needs the pubkey-alone index and degrades to a
  full scan per author without it.

Also: corrected the KDocs (the old text implied an efficient index-only
distinct that wasn't guaranteed), added a giftwrap-exclusion test, and added
FsAuthorsMissingOutboxTest — the only coverage of the IEventStore DEFAULT
implementation, which EventStore always overrides.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuLzfXyVZ16ozG8oJ7hBBc
2026-07-09 02:50:39 +00:00
Claude 7bd957c3c4 perf(quartz): snapshot ids for reconcile + harden NegentropyStoreSync
Audit follow-ups on the sync engine:

- Perf: syncGroup reconciled against a full store.query<Event>(filter),
  decoding the entire local matched set (~1 KB/event) just to read ids +
  created_at and to index events for a small residual upload. Reconcile now
  uses store.snapshotIdsForNegentropy (id + created_at only, ~40 B/entry) and
  the uploader fetches only the residual haves by id. Peak memory drops from
  O(all local matches) to O(residual) — matters when a relay hosts a large set.

- Bug: sync() promised best-effort ("one bad relay can't abort the set") but
  syncGroup only caught NegentropySyncException, so any other failure (store
  I/O, an unexpected throw) escaped async and cancelled every other relay via
  awaitAll. Each group now runs under a guard that records the failure instead.

- Bug: the page-fallback catch (Exception) swallowed CancellationException,
  breaking cooperative cancellation. Both new catch sites rethrow it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdEvjsZ81XuUtdJsVzmHxt
2026-07-09 01:52:02 +00:00
Claude cf4eddeaad test(quartz): benchmark authorsMissingOutbox generic vs sqlite at 1M events
Adds AuthorsMissingOutboxBenchmark (gated behind -PprodRelayBench=1, like the
other prod benches). It syncs a real sample from relay.damus.io (kind 1 notes +
kind 10002 relay lists), replicates it to 1,000,000 stored rows while preserving
the real author set and outbox-owner set, then times the two shipping
implementations of authorsMissingOutbox() on the same store:

  - generic: the IEventStore interface default (decodes every event via
    query(Filter()))
  - sqlite:  EventStore's SELECT DISTINCT pubkey ... NOT EXISTS

Both are asserted to return the same set, matching the seeded ground truth.

Measured on a 4-core container, 1,000,000 events (best of 3):
  generic  138,880 ms
  sqlite     2,623 ms   → ~53x faster

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuLzfXyVZ16ozG8oJ7hBBc
2026-07-09 01:22:14 +00:00
Claude 2f11c134ab refactor(quartz): generalize the updater engine into NegentropyStoreSync
Extracts GrapeRankUpdater's per-relay sync engine into a standalone
NegentropyStoreSync in the relay-client accessories, so any caller can
two-pass sync an arbitrary `relay -> filters` set against a local store.

Given an INostrClient + IEventStore it syncs each (relay, filter) group:
a bidirectional NIP-77 reconcile into/from the store (down/up), a deletion
settle over the residual (applyDown downloads the relay's kind:5 when an
uploaded record was rejected), and a paged-download fallback when a relay
can't reconcile. sync() runs many groups with relays concurrent and each
relay's own filters sequential (so one relay never exceeds its subscription
budget). Directions and bounds are a Config; every group is best-effort and
its outcome is a GroupResult. This is also the reusable engine `amy sync`
open-codes today.

GrapeRankUpdater now only owns the GrapeRank specifics: it reads kind:10002,
inverts to write-relay -> authors (the outbox model), fans that into one
filter per (relay, author chunk), hands the set to NegentropyStoreSync, and
folds the per-group results back up per relay. Its public Config/Result and
the CLI wrapper are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdEvjsZ81XuUtdJsVzmHxt
2026-07-09 00:37:28 +00:00
Claude 574320cf22 feat(quartz): add IEventStore.authorsMissingOutbox() anti-join query
Adds a whole-store query returning every distinct author with at least
one stored event that has NO NIP-65 relay list (kind 10002 / outbox).

This is a set-difference the positive-only nostr Filter grammar can't
express (there is no "NOT kind 10002"), so it lives as a dedicated
IEventStore method rather than a query(Filter). The interface carries a
correct default (collect authors-with-outbox, then stream events keeping
the rest — O(events)); SQLiteEventStore overrides it with a single
SELECT DISTINCT ... NOT EXISTS that seeks the outbox check on the
(kind, pubkey, created_at) index.

"Missing" is relative to what the store holds: an author whose only
10002 was deleted (NIP-09) or expired (NIP-40) is reported as missing
again, since no row remains.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuLzfXyVZ16ozG8oJ7hBBc
2026-07-09 00:18:24 +00:00
Claude 4a686fc057 refactor(quartz): extract GrapeRankUpdater outbox-model WoT refresh utility
Moves the `amy graperank update` logic out of the CLI and into quartz as
GrapeRankUpdater, alongside GrapeRankDataCrawler in experimental/graperank,
so Android and any other quartz consumer can run the same refresh.

Given an INostrClient + IEventStore it reads every kind:10002 in the store,
inverts them into a write-relay -> authors map (the outbox model), then runs
one NIP-77 negentropy reconcile per write relay scoped to its authors:
bidirectional content sync into/from the store, deletion settle over the
residual (applyDown downloads the relay's kind:5 when an uploaded record was
rejected because the author retracted it), and a full paged-download fallback
when a relay can't reconcile. Bounds and directions are a Config; per-relay
and aggregate outcomes are returned as a Result.

The CLI `graperank update` is now a thin wrapper: it parses flags, builds the
Config, and renders GrapeRankUpdater.Result as text/JSON — no sync logic left
in cli/ (all reconcile/window/back-pressure/deletion logic lives in quartz's
relay-client accessories, which GrapeRankUpdater composes).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdEvjsZ81XuUtdJsVzmHxt
2026-07-09 00:12:22 +00:00
Claude b8b25060fb fix: drain buffered event on EOSE in fetchFirst to avoid race
A relay sends its matching events before its EOSE, so both an event and
the relay's completion can sit buffered in their channels at the same
time. The select() over the two channels picks a ready clause at random,
so it could process the doneChannel completion first, empty `remaining`,
and exit the loop while the matching event was still unread — returning
null instead of the event.

On a relay completion, drain the event channel first and treat any
already-buffered event as the result before marking the relay done.
2026-07-08 19:15:11 +00:00
Claude d9dee8967b fix: resolve compiler warnings across modules
Clears real Kotlin compiler warnings surfaced across quartz, cli,
relayBench, amethyst, and desktopApp:

- quartz Sha256/EventHasher/ScratchLocal: ThreadLocal.get() is nullable
  in Kotlin; assert non-null (withInitial never yields null).
- quartz GitHttpClient: PriorityQueue.poll() under isNotEmpty() is
  non-null; assert it.
- relayBench CorpusDownloader: drop redundant !! on smart-cast Long;
  Jackson fields() -> properties().
- cli GrapeRankCommand: drop redundant ?. where latest is smart-cast.
- PodcastRemoteContent: OkHttp body is non-null; drop dead elvis.
- Dead/redundant expressions: remove no-op when-branch values and a
  redundant trailing Unit (HomeScreen, LocalCache, EmbeddedTabLayer,
  ParticipantHostActionsSheet, NestActionBar, ControlWhenPlayerIsActive,
  ShareNoteAsImageScreen exhaustive-when else).
- CalendarEventDetailScreen / SetPasswordDialog / ProfileClinkOfferResolver:
  drop always-true conditions (reorder to keep smart-casts).
- WalletColumnScreen: OkHttp body non-null; drop unreachable null-guards.
- PcmTapRegistry: the @OptIn used androidx.annotation.OptIn, which does
  not opt into Kotlin's ExperimentalCoroutinesApi; use kotlin.OptIn.
- GitRepositoryScreen: suppress the standard ViewModel-factory cast.
- PushNotificationReceiverService: suppress override-of-deprecated.
- Desktop GlobalScope call sites: @OptIn(DelicateCoroutinesApi::class).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GMqkg1ndvFihEwZcENiRs
2026-07-08 18:31:10 +00:00
Claude 00246c6ae2 fix: resolve compiler and Gradle deprecation warnings
- GrapeRankPublisher: dTag() is non-null (""), so the Elvis on the
  grouped target was dead code; skip blank targets via ifBlank instead.
- amethyst: migrate deprecated resourceConfigurations to
  androidResources.localeFilters (same locale qualifiers).
- desktopApp: replace deprecated compose.desktop.uiTestJUnit4 accessor
  with the direct org.jetbrains.compose.ui:ui-test-junit4 dependency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GMqkg1ndvFihEwZcENiRs
2026-07-08 18:01:55 +00:00
Vitor PamplonaandGitHub de43c0bc7f Merge pull request #3499 from vitorpamplona/claude/mls-secrettree-preservation-8passk
Persist SecretTree ratchet positions across MLS group restores
2026-07-08 12:52:36 -04:00
Vitor PamplonaandGitHub e3e9e2fa20 Merge pull request #3498 from vitorpamplona/claude/negentropy-sync-deletions-t2a7sf
NIP-77 deletion sync: two-pass settle over the reconcile residual
2026-07-08 12:05:07 -04:00
Claude 0145f8bdcb refactor: extract deletion-settle loop into a quartz INostrClient accessory
The two-pass deletion convergence is protocol logic, not CLI assembly, and the
geode mirror is a near-term second consumer — so move it out of SyncCommand into
a reusable accessory alongside the rest of the negentropy family.

quartz: negentropySettleDeletions(relay, filter, store, sendUp, applyDown, …) —
re-reconciles after a content settle and resolves only the residual: publishes
our covering deletions up (sendUp) and/or ingests the relay's kind-5 down
(applyDown, vanish never auto-applied), looping until a round resolves nothing.
Returns DeletionSettleResult(sentUp, appliedDown, rounds). Everything it needs is
already quartz (negentropyReconcileIds, fetchAll, deletionsCovering,
publishAndConfirm, Event.verify, IEventStore), so it carries no CLI dependency.

SyncCommand's pass 2 collapses to a single call; pass 1 (content) is unchanged.
Catalogued in the accessories README.

Tests: DeletionSyncTest drives the accessory end-to-end both ways (sendUp → relay
converges to gone; applyDown → local converges to gone), on top of the existing
deletionsCovering unit + manual-wiring cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgL1WTV4Hkp2uuXcUHCHGt
2026-07-08 14:42:20 +00:00
Claude 677c0ee207 refactor: deletion sync as a post-settle residual pass (both directions, O(residual))
Replace the per-need-event fetch (which pulled the whole need set just to read
metadata — an O(db) regression on large syncs) with a second reconcile pass over
the residual, per the "settle, then diff, then explain what didn't converge" idea.

Pass 1 is the plain content sync again (drain needs, publish haves) — zero
deletion overhead. Pass 2+ re-reconciles; the leftover diff is exactly the
deletion mismatches, and only that (tiny) set is fetched:
- residual need (relay has it, we still lack it after --down) = we deleted it →
  publish our covering deletion up so the relay drops it;
- residual have (we have it, relay still lacks it after --up) = the relay deleted
  it → pull the relay's covering kind-5 down and apply locally (vanish is NOT
  auto-applied on pull — account-wide blast radius).
Loops until a round resolves nothing (converges + self-verifies).

So `amy sync` makes the relay honor our deletions; `--up` makes us honor the
relay's; `--up --down` converges both ways. Cost is one cheap reconcile + the
residual regardless of database size — the large-DB bottleneck is gone by
construction, not by heuristics.

quartz: deletionsCovering is now source-agnostic (takes a query lambda) so the
same coverage rule runs against the local store (up) or the relay (down); the
IEventStore overload is the local convenience.

Tests: DeletionSyncTest gains the down-direction end-to-end (relay deleted →
local removes) alongside the up-direction and the per-form unit cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgL1WTV4Hkp2uuXcUHCHGt
2026-07-08 14:25:37 +00:00
Claude 07d982b5b5 fix(marmot): preserve SecretTree ratchet across restore to stop generation reuse
MlsGroupState reconstructed the SecretTree from encryption_secret alone, so
every restore rewound each sender's generation counter to 0. The restored
local member then re-emitted generation 0 within the same epoch — reusing the
AEAD key+nonce (a confidentiality break) and getting rejected by strict
receivers (openmls / MDK / Whitenoise) that forbid generation reuse, per
RFC 9420 §9.

Two parts:
- Persist per-sender ratchet positions. SecretTree gains export/importSenderStates;
  MlsGroupState carries them as an optional field (STATE_VERSION 2, v1 blobs still
  decode as empty = legacy behavior); saveState/restore wire them through.
- Persist after every send. MlsGroupManager.encrypt now saves group state, not
  just commits — application sends advance the ratchet but previously never hit
  the store, so a restart between two commits still reset it.

Regression tests: a peer that consumed generation 0 accepts the restored
sender's next message (single + multi-send), encrypt persists the ratchet
between commits, and a v1 blob still decodes/restores.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G6uT4xzjty1xosZBkb3sHA
2026-07-08 14:16:03 +00:00
Vitor PamplonaandClaude Opus 4.8 3cba102a09 perf(graperank): faster crawl (cap 100→16, dead-discovery shedding, fewer sweep barriers) + relay diagnostics
Speeds up a from-scratch GrapeRank crawl ~25-30% at equal completeness on a
drift-controlled A/B, by:
- lowering the per-relay concurrent-sub cap 100→16 — the old 100 drowned popular
  relays (damus/nos.lol) in concurrent giant REQs, driving them to time out; 16
  restores their responsiveness (damus yield 0%→14%) and is still generous for
  the single-user fetches other amy commands do,
- shedding proven-dead relays from the kind:10002 discovery sweep instead of
  re-hammering refusing indexers every round,
- trimming the sharded backbone sweep 6→2 rotations (Phase A was ~36% of the
  crawl at half Phase B's per-list efficiency; 2 clears the bulk with no
  completeness loss).

Also adds relay observability under --diagnose to document how relays reply to
our queries: per-relay telemetry (outcome mix, yield, latency, worst time-sinks),
a LIVE / THROTTLED / UNREACHABLE classification table with the limits we settled
on per relay, and per-round Phase-A/Phase-B timing; plus contact_lists_by_hop in
the sync result for per-hop completeness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 10:02:24 -04:00
Claude c57681b3e9 docs: catalog INostrClient relay-client extensions so they're discoverable
The one-shot/high-level relay ops (fetchAll, fetchFirst, fetchAllPages,
publishAndConfirm, count, negentropy sync/reconcile, …) are INostrClient
extension functions spread across ~8 files with no index, so they don't surface
under "usages of NostrClient" or in completion — easy to miss and re-implement
(as just happened with a bespoke fetchRaw duplicating fetchAll).

- Add accessories/README.md cataloging each public extension with a one-line
  "use when".
- CLAUDE.md (Feature Workflow): point at that package/README before hand-rolling
  a subscribe/REQ/publish loop.
- relay-client skill: add a Related note steering headless/one-shot callers to
  the accessories instead of Subscribable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgL1WTV4Hkp2uuXcUHCHGt
2026-07-08 13:25:13 +00:00
Claude af7c6c11e1 feat: send exactly the deletions that cover a relay's need events (id/addr/vanish)
Refine the sync deletion rule to what was asked: for the events the relay HAS
that we LACK (the reconcile need set), publish only the local deletions that
would actually make the relay remove them — and nothing else, not other
deletions by the same author.

Determining coverage needs the need event's author/address/created_at, which we
don't have for an id we lack, so we fetch the need events (raw — no verify, no
store) purely for metadata. quartz gains IEventStore.deletionsCovering(events,
relay), which maps server-held events to the covering local deletions across all
three forms:
- NIP-09 id-based: a kind-5 with an `e` tag naming the event id;
- NIP-09 address-based: a kind-5 with an `a` tag naming the event's
  addressable/replaceable coordinate, at/after it (created_at <= deletion);
- NIP-62 vanish: a kind-62 by the event's author, targeting this relay, issued
  after it (created_at < vanish).

SyncCommand's need workers now fetch each need batch once (Context.fetchRaw),
publish its covering deletions (deduped across workers), and — when --down —
store the rest; anything we deleted is rejected by the store's own tombstone.
Nothing is pulled down or applied locally, so it cannot over-delete the store.

DeletionSyncTest covers each form (with cutoff and wrong-relay negatives) plus an
end-to-end reconcile → cover → publish that removes the note on the relay.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgL1WTV4Hkp2uuXcUHCHGt
2026-07-08 02:45:47 +00:00
Claude e09a939b08 refactor: reduce deletion sync to "send deletions for need ids", nothing else
Per the actual requirement, deletion propagation is exactly: for the ids the
relay HAS that we LACK (the negentropy need set), if we hold a kind-5 deletion
targeting one of them, publish that deletion up — so a note we deleted is
deleted on the relay too instead of being re-downloaded. Only the need ids,
only kind-5, up only.

This removes all the machinery the earlier approach accreted and that the audit
flagged as over-broad / data-loss-prone:
- deleted NostrClientDeletionSyncExt (the bidirectional side-channel, author
  scoping, vanish gating, kind selection);
- reverted geode MirrorWorker to base (no deletion side-channel, live-sub
  changes, catch-up ordering, or convergence changes);
- dropped the 3-phase SyncCommand flow (deletions-first pull, author-scope
  derivation, reject-reaction backstop, --sync-vanish, deletions_* output).

The new path pulls nothing down and applies nothing locally, so it cannot
over-delete the store, and it needs no author scoping — the need set already
bounds it. Kind-62 is intentionally excluded: a vanish is not "of an id".

Emits deletions_sent. DeletionSyncTest now exercises the exact wiring
(reconcile → look up local kind-5 by its e tag for the need ids → publish),
including the negative case (a need id we never had sends nothing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgL1WTV4Hkp2uuXcUHCHGt
2026-07-08 02:29:52 +00:00
Claude 17687e43fc fix: bound deletion sync scope; stop mass over-deletion (audit fixes)
An audit (adversarial-verified) found the deletion side-channel over-deletes
and over-propagates. Root cause: deletionSideChannelFilter fell open to
authors=null for any non-author-scoped content sync, so `amy sync --kind 1`
reconciled the RELAY'S ENTIRE kind-5/62 history and applied it to the personal
FsEventStore — every kind-5 deleting its targets + installing an id-tombstone
for every target id, every ALL_RELAYS kind-62 wiping all of a pubkey's events
(all kinds), and pushing our whole local deletion history up. Data loss plus a
full-history reconcile on every scoped sync.

Fixes:
- Bound the side-channel to the authors we actually hold content for (filter
  authors ∪ local matched-set authors), never the relay's population. Skip when
  that scope is empty; Phase 3's reject-reaction covers the author-less case.
- Kind-5 (precise, owner-scoped) propagates by default; kind-62 vanish is opt-in
  via --sync-vanish (its blast radius always exceeds a content sync's scope).
- excludesDeletionKinds() now checks each deletion kind independently
  (`--kind 1,5` no longer silently drops kind-62); the side-channel reconciles
  only the missing kinds.
- amy Phase 1 is best-effort: a deletion-reconcile failure records deletions_error
  and falls through to content, never aborting the primary sync (matches geode).
- Mirror up-catch-up converges on whether a PUBLISHABLE event was pushed, not raw
  haveCount — a vanish targeting another relay no longer burns all 8 rounds every
  startup. Mirror keeps its (correct) global scope for relay-to-relay replication.

Helper API: negentropyPropagateDeletions gains scopeAuthors + deletionKinds;
deletionSideChannelFilter takes authors + deletionKinds and returns only the
missing kinds. Tests updated for the new semantics.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgL1WTV4Hkp2uuXcUHCHGt
2026-07-08 02:14:08 +00:00
Claude 683f993c7b feat: propagate deletions (NIP-09/62) across negentropy sync
NIP-77 reconciles by event id over the content filter, so a scoped sync
(`--kind 1`) never carries the kind-5/62 that deletes one of those notes:
the deletion stays stuck on whichever side issued it while the target
lives on forever on the other. Add a deletion side-channel that reconciles
kinds 5 & 62 on their own, independent of the content filter.

quartz: NostrClientDeletionSyncExt — DELETION_PROPAGATION_KINDS,
Filter.excludesDeletionKinds()/deletionSideChannelFilter() (kinds 5/62 scoped
to the same authors, no time window since a deletion's created_at is not its
target's), shouldPropagateDeletionUp() (kind-5 always; kind-62 only to a relay
it targets, honoring the vanish's declared relays), and
negentropyPropagateDeletions() — one bidirectional reconcile that streams
have→upload and need→download.

amy sync: run the side-channel bidirectionally regardless of --up/--down
whenever the filter excludes 5/62; emits deletions_{need,have,downloaded,
uploaded}; --no-sync-deletions opts out.

geode MirrorWorker: thread a per-upstream deletionScope through both catch-up
phases and both live subs (down + up), in the mirror's configured direction;
relax down containment to accept in-scope deletions, gate kind-62 pushes by
target relay, and carry the deletion filter on re-subscribe so a reconnect
never drops it.

Tests: DeletionSyncTest (up/down propagation + filter/vanish-gate units) and
MirrorDeletionSyncTest (a kind-scoped down mirror still removes the note).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgL1WTV4Hkp2uuXcUHCHGt
2026-07-08 00:00:24 +00:00
Claude 24c1ef3100 Merge remote-tracking branch 'origin/main' into claude/graperank-wot-cli-qreg2a
# Conflicts:
#	cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StoreCommands.kt
2026-07-07 23:18:50 +00:00
Claude db39536bde feat(graperank): live progress heartbeat + optional builder for persist-only sync
The crawler only logged once per round, so a deep hop (140k users, minutes of
work) went silent between lines. Add a heartbeat ticker on the background scope
that emits every few seconds with the current round's completion (a real X/Y %
against the round's known pending target), a rolling fetch rate + rough ETA for
it, and live counts (events stored, relays parked/dead) — and a "finishing"
line while draining the parked tail. Scoring stays sub-second, so it keeps its
per-sweep lines and needs no ticker.

Also make crawl()'s builder nullable: null runs a persist-only pass (every event
still lands in the store, the frontier still expands off each contact list) with
no in-memory graph — the basis for a `sync` that loads data without scoring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
2026-07-07 23:09:09 +00:00
Claude f95df91d6d chore(quartz): drop AdaptiveRelayLimiter + relay-URL rejection logs to debug
The per-relay concurrency/rate throttle notices and the "Rejected <url>"
normalizer messages fire constantly during a large crawl (thousands of
rejected/throttled relays) and are operational detail, not warnings. Move
them from Log.w to Log.d so they stay available under debug logging without
flooding a normal run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
2026-07-07 22:15:29 +00:00
Claude 660bd86dc7 perf(graperank): idle-based park timeout so streaming relays aren't cut mid-flight
The park window's timeout was absolute from subscription open, so a relay
still actively streaming a large result set once it passed parkTimeoutMs was
unsubscribed and its untransmitted tail lost. Reset the window on every
incoming event (a conflated activity signal drives a select against the
terminal deferred), so a parked subscription is closed only after
parkTimeoutMs of actual silence — never while events are still arriving. The
fast window stays absolute: it only decides when to hand a slow relay to the
background park lane, which loses nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
2026-07-07 22:11:19 +00:00
Claude 560b95c2ec fix: resolve Kotlin compiler warnings in quartz and quic
- Replace unused Unit/null expressions in statement-position when branches
  with empty blocks (CommandSerializer, QuicConnection, QuicConnectionParser,
  Http3FrameReader, WtPeerStreamDemux).
- Suppress DEPRECATION on KindNames.names, which intentionally registers the
  deprecated GitReplyEvent and TorrentCommentEvent kinds for display.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018nqdy4VTLKidUWzGTJPja9
2026-07-07 21:32:41 +00:00
Claude 6b957e1950 perf(graperank): park slow relays in the background instead of blocking rounds
The crawl was round-synchronised: each hop drained all its relays and only
started the next hop after the slowest one reached EOSE or the timeout. That
made waiting for slow-but-alive relays expensive — every hop paid its slow
tail before the next hop's fast relays could begin — so a long timeout for
completeness cost ~2x wall-clock (measured), and a short one dropped the slow
relays' data.

Diagnostics on a ~190k-user crawl showed the genuinely-slow set is a stable
~30 relays that DO reach EOSE, just in 5-25s. So decouple the two concerns:

- drainGated now drains on the FAST `timeoutMs` that sets the round cadence. A
  relay still streaming when it elapses is not cut but PARKED: it hands its
  open subscription to a background scope (releasing its AdaptiveRelayLimiter
  permit so the round moves on), keeps receiving for up to the new
  `parkTimeoutMs`, and its late events are persisted + its late contact lists
  pushed to a crawl-wide lateHarvest channel.
- The round loop folds late harvest into the graph between rounds and won't
  converge until the frontier is empty AND no relay is still parked — so the
  crawl waits for slow relays for completeness without paying that wait in each
  round's wall-clock.

Graph state stays single-writer: parked coroutines only touch the store,
seenIds, and the channel — never hopOf/done/builder. Persistence moved from a
single per-drain consumer to a shared `persist()` that fast and parked units
both call; crawl-wide dedup is now race-safe via ConcurrentSet.add's atomic
test-and-set (an id is added only after a good signature, so no duplicate
reaches the store's UNIQUE constraint and a forged copy can't suppress the
genuine one).

Also carries the --diagnose slow-relay logging (relay + filter + elapsed for
every slow/parked REQ, so a human can replay it) and keeps --drain-concurrency
at the validated default of 24 (an A/B at 64 was ~2x slower with more dead
relays). New --park-timeout flag (default 40s; set <= --timeout to disable).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
2026-07-07 20:13:21 +00:00
Claude c9ee79beba feat(graperank): log slow/timed-out relays with their query under --diagnose
Add a --diagnose slow-relay log: every content drain that reaches its terminal
(EOSE or timeout) slower than SLOW_DRAIN_LOG_MS, or times out entirely, is
recorded with the offending relay URL, the failure/EOSE reason, elapsed ms, and
the exact filter shape (kinds + author count + first authors). This lets a human
replay that precise REQ later to understand why the relay lags. Gated on
--diagnose so there is no per-group timing/collection overhead otherwise.

Make the content-drain fan-out configurable via a new --drain-concurrency flag
(Config.drainConcurrency), replacing the DRAIN_CONCURRENCY constant. Default
stays at the validated 24: an A/B at 64 ran ~2x slower with more dead relays
(a higher global fan-out re-floods busy hubs faster than the per-relay demotion
catches up), so the flag is a probe knob, not a speedup. Client WebSocket pings
were also tried and reverted — busy-but-alive relays don't reliably pong while
their query handler runs, so pinging just cut them as dead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
2026-07-07 19:10:33 +00:00
Claude 9eec6323c8 perf(quartz): don't re-query a relay that EOSE'd without a user's list
The per-user retry counter was blunt: it bumped attempts whether an outbox was
dead, timed out, or cleanly EOSE'd with no event — so a straggler kept being
re-queried against a live relay that had already definitively answered it lacks
their kind:3. Distinguish the cases: drainGated now reports the relays that fully
EOSE'd (answeredOut); the consumer records, per user, the relays that answered but
did not return their contact list (askedEmpty); routeByOutbox excludes those from
the user's candidate relays. A timed-out relay is never added (it might just be
slow — still worth a retry), only a clean-EOSE-empty one; dead relays stay pruned
as before.

Measured on --max-hops 3: redundant fetching dropped ~8% (74k -> 68k events
stored). It does NOT move the wall-clock tail, though — that tail is dominated by
timeout/dead outboxes (the retryable case), not EOSE-empty relays. The wall-clock
lever remains the timeout retry budget (MAX_OUTBOX_ATTEMPTS / drain timeout).
2026-07-07 18:21:52 +00:00
Claude 9da382b698 refactor(quartz): extract GrapeRankPublisher from the CLI command
Mirror the crawler extraction on the emit side: the NIP-85 kind:30382 card
reconcile + publish logic (existingCards read-back, rank-diff upsert, stale-card
kind:5 retraction batched under the 64KB event cap) moves out of GrapeRankCommand
into a reusable GrapeRankPublisher in quartz experimental/graperank. It takes an
IEventStore for the prior-card read-back and an injected publish function
(event + relays -> per-relay ack), so the store/relay wiring stays in the app
while the reconcile logic is reusable (e.g. by the Android app).

GrapeRankCommand is now a thin orchestrator: crawl (GrapeRankDataCrawler) ->
score (GrapeRank) -> publish (GrapeRankPublisher). The account-specific bits stay
in the CLI: operator-key derivation, the observer's kind:10040 discovery pointer,
and the operator/register/providers sub-verbs.
2026-07-07 17:56:06 +00:00
Claude f7c3aef6fc perf(quartz): batch inserts + crawl-wide dedup in GrapeRankDataCrawler
The crawl re-verified and re-inserted the same event many times: the outbox
model mirrors each event (especially kind:10002 relay lists) across relays,
indexers, and rounds, but dedup lived in a per-drain SeenIds, so only the copies
within one drain were caught. Add a crawl-wide seen-set (thread-safe ConcurrentSet
of event ids, shared across all 24 concurrent drains and every round), checked
before verify and added only after verify so a forged copy can't suppress the
genuine one. Group-commit the store writes via IEventStore.batchInsert instead of
one transaction per event.

Measured on a from-scratch --max-hops 3 crawl: events actually verified+stored
dropped ~34% (112k -> 74k) and verify time fell in lockstep. The write path now
also reports verify/insert timing + events_stored in Stats, exposed as verify_ms/
insert_ms/events_stored on the CLI, and takes an --insert-batch knob.

Finding: with the work reduced, inserts serialize on SQLite's single writer
mutex rather than transaction count, and the crawl's wall-clock ceiling is the
drain-timeout retry tail on dead outboxes, not the disk.
2026-07-07 17:51:27 +00:00
Claude 41e88695e0 refactor(quartz): simplify GrapeRankDataCrawler after extraction review
Cleanups from a reuse/simplification/efficiency/altitude review of the crawler
extraction:

- Collapse the redundant `discovered` set into `hopOf` — a user is discovered
  iff it has a hop stamp, so the two always held the same key set. The frontier
  is now `hopOf.keys`; one fewer collection to keep in sync.
- Drop the unused `Stats.discovered` / `Stats.deadRelays` fields (no reader —
  the CLI reports rounds / relaysContacted / hopHistogram / downloadMs).
- Extract a single shared verify-then-store sink, `IEventStore.verifyAndInsert`,
  and route both the crawler and `Context.verifyAndStore` through it instead of
  each carrying its own verify + insert + UNIQUE-swallow copy.
- Fast-path the present-key hit in `ConcurrentMap.getOrPut` (jvmAndroid) so the
  crawl's hot relay-hint accumulation stops allocating a mapping-function closure
  on every call.
- Hoist the repeated `crawlStats?.hopHistogram` null-plumbing in GrapeRankCommand.
2026-07-07 16:00:39 +00:00
Claude 667358a06a refactor(quartz): extract GrapeRankDataCrawler to commonMain
The web-of-trust crawl (~400 lines: outbox routing, sharded backbone sweep,
Phase-B worker pool, relay-list discovery, report-deletion fetch, warm pool)
was making the CLI's GrapeRankCommand unmaintainably large. Move it into a
reusable, KMP-portable GrapeRankDataCrawler in quartz commonMain.

The crawler takes a NostrClient + IEventStore + AdaptiveRelayLimiter, injected
relay policy (discovery + content-fallback sets, since those defaults live in
app code, not the protocol library), and a log callback; it streams contact
lists into a TrustGraphBuilder and returns crawl Stats. GrapeRankCommand shrinks
to arg-parsing + offline load + scoring + publish + sub-verbs, delegating the
online path to the crawler.

To reach commonMain (portable to every target, incl. iOS):

- Add ConcurrentMap / ConcurrentSet expect classes under utils/concurrent, with
  jvmAndroid actuals (java.util.concurrent) and native actuals (copy-on-write
  over kotlin.concurrent.atomics.AtomicReference, mirroring ConcurrentHashCache).
  commonMain has no ConcurrentHashMap, and the crawl's producer/consumer/drain-
  worker state needs atomic getOrPut/merge plus a concurrent set.
- Move AdaptiveRelayLimiter and DrainFailure/classifyDrainFailure from cli to
  quartz commonMain (java atomics -> kotlin.concurrent.atomics, ConcurrentHashMap
  -> ConcurrentMap, System.currentTimeMillis -> TimeUtils.nowMillis, stderr -> Log).
- The gated drain (REQ-size splitting, per-relay permits, verify+store) moves into
  the crawler; Context.drain loses its now-unused gatePerRelay path.

Net: cli -1077 lines; the crawler + relay machinery are now reusable by the
Android app. Adds ConcurrentCollectionsTest; verified via JVM + commonMain
metadata compile, the wot/graperank suites, and a bounded live crawl.
2026-07-07 15:41:31 +00:00
Claude 6d64b7b41c feat(quartz): include exception type in relay connect-failure message
BasicRelayClient collapsed a connection failure into a message string built
from the throwable's text alone. Message text is localized and inconsistent
across platforms, so a listener can't reliably tell a busy relay (a connect
timeout) from a dead one (bad domain / TLS misconfig) from it. Always append
the exception class name (SocketTimeoutException / UnknownHostException /
SSLHandshakeException / ConnectException …), which is stable, so listeners
can classify the failure by type. Message text is preserved; the type is
added in parentheses. Updated the one test that pinned the old format.
2026-07-07 13:10:58 +00:00