Commit Graph
2615 Commits
Author SHA1 Message Date
Claude 1cb930f941 Merge remote-tracking branch 'origin/main' into claude/concord-quartz-amethyst-plan-0oy779 2026-07-11 00:21:29 +00:00
Vitor PamplonaandGitHub 57aaf69073 Merge pull request #3524 from vitorpamplona/claude/relay-icons-display-timing-7c3n7c
Fix relay attribution in gift-wrap chains and Marmot messages
2026-07-10 19:11:47 -04:00
Claude f151cb74fc fix: drill relay attribution into Marmot kind-445 inner notes
Audit follow-up to the gift-wrap relay-icon fix: Marmot group chat rows
render the decrypted inner note (kind 9/7) via the standard chat feed with
RelayBadgesHorizontal, but nothing ever populated that note's relay list —
OK acceptances and relay deliveries all landed on the kind-445 envelope,
which has no link to its inner event.

- GroupEvent now implements HasInnerEvent (same @Transient @Volatile
  innerEventId pattern as GiftWrapEvent/SealedRumorEvent), so
  LocalCache.addRelayToNoteAndInners drills 445 -> inner for both OK
  confirmations and duplicate EVENT deliveries. RouteMaker is unaffected:
  it gates on the concrete wrap types before casting to HasInnerEvent.

- GroupEventHandler sets innerEventId at decrypt time and copies the
  envelope's accumulated relays down to the inner note (looked up by
  event.id, not the eventNote/publicNote params, which belong to the
  triggering event when replayed from retryPendingFor).

- sendMarmotGroupMessage sets innerEventId on the freshly built envelope
  before consuming/publishing, so acceptances for sent group messages
  reach the rendered note as soon as the inner note is indexed.

Also audited the remaining chat-rendered types: NIP-04 PrivateDmEvent,
ChatMessageEvent rumors, NIP-C7 ChatEvent, ChannelMessage/Ephemeral/
LiveActivities messages all route through consumeRegularEvent (duplicates
covered by the shared helper), and NIP-37 drafts render the wrap note
itself with markAsSeen covering the version/addressable pair.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YEiq1NMK3q12KGQ2yYhPEp
2026-07-10 23:01:45 +00:00
Claude b48cc2c117 Merge remote-tracking branch 'origin/main' into claude/concord-quartz-amethyst-plan-0oy779
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt
2026-07-10 22:53:52 +00:00
Claude 21c6e60e18 feat(concord): mobile "Make admin" / role toggle
Adds an owner-only role toggle to the Concord note quick-action menu, alongside
Ban:

- Account.concordAdminTarget gates the action to the owner (only rank 0 strictly
  outranks the position-1 Admin role, as the resolver requires), never the
  owner's own note or the owner as target, and reports whether the author is
  already an admin (via the new AuthorityResolver.rolesOf accessor).
- makeConcordAdmin mints a default Admin role (all management + moderation
  permissions, position 1) if the community doesn't have one yet, then grants it;
  removeConcordAdmin revokes via an empty grant.
- The menu item flips between "Make admin" and "Remove admin" and fires
  toggleConcordAdmin.

Extends ConcordModerationTest to cover rolesOf and revoke-via-empty-grant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-10 22:41:14 +00:00
Claude d1ba04ad52 fix(relayauth): pre-merge audit — auth retry budget + lost-prompt window
Two bugs found in a pre-merge audit:

- quartz PoolEventOutboxState: auth-required NAKs only spared the `responses`
  budget, but `tries` (grown by every send/re-pump and NOT auth-aware) still
  accumulated across reconnects, so a slow/flapping AUTH handshake could exhaust
  Tries.isDone() and drop the event — with a spurious onEventGaveUp — before AUTH
  landed. Now an auth-required NAK resets the relay's retry budget (it responded,
  so it's up and just wants auth). Regression test added.

- RelayAuthPromptBus used a replay=0 SharedFlow, so a challenge that resolved to
  ASK before RelayAuthPromptHost subscribed (cold start / account switch) was
  dropped and the auth coroutine stalled the full timeout then DISMISSed. Add
  replay so late subscribers recover pending prompts (the host already filters
  resolved ones). Regression test added.

Also record the as-built design (Always/Never/Custom + toggles, venues, give-up
toast, known deny-relay-outbox limitation) in the plan doc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:13 +00:00
Claude 9179bd9d8d feat(quartz): signal when an outgoing event exhausts its retry budget
Instead of silently dropping an event once its per-relay retry budget is
spent, PoolEventOutbox now reports it: PoolEventOutboxState.newTry returns
whether the attempt gave up on the relay, PoolEventOutbox.onSent surfaces
the dropped event, and NostrClient notifies a new (default no-op, so
non-breaking) RelayConnectionListener.onEventGaveUp(relay, event). Lets a
host surface a failed delivery rather than lose it silently; the event may
still be pending on other relays. Unit-tested via the outbox try budget.

Note: timed retry backoff (the other half of this item) is intentionally
deferred — applied in the shared syncState path it would also delay the
post-auth resend and regress the auth-required fix, so it needs
trigger-aware handling designed separately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:10 +00:00
Claude 38b2e65362 feat(quartz): expose pending outbox events per relay for auth context
Add INostrClient.activeOutboxEvents(url) (backed by
PoolEventOutbox.activeOutboxEventsFor) returning the full events still
pending delivery to a relay, not just their ids like activeOutboxCache.
This lets a host explain *why* a relay is being authenticated with —
e.g. a pending kind-1059 gift wrap means we're sending a DM to its
recipient — by inspecting kind/tags. Combined with the existing
activeRequests(url) filters, it is the generic challenge context the
NIP-42 decision hook needs. Updates the INostrClient test fakes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:09 +00:00
Claude 9c618f9ba1 fix(quartz): don't drop outgoing events on NIP-42 auth-required NAKs
PoolEventOutboxState treated an `auth-required` OK-false the same as any
transient failure, recording it against the per-relay Tries budget
(responses > 2 drops the event on the next send attempt). Relays that
NAK every unauthenticated EVENT could therefore exhaust the budget and
drop the message before the AUTH handshake completed — the event was
gone by the time syncFilters re-sent it after the auth OK.

Treat `auth-required` as a deferred state instead: keep the relay in
relaysRemaining and record no failure, so the existing
syncFilters-after-auth path redelivers it. Mirrors the behavior already
present in StandaloneRelayClient. Terminal rejections (invalid/pow/
replaced/deleted) and ordinary transient errors are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:09 +00:00
Claude 1cffd8db18 fix: propagate relay acceptances into gift-wrap chains for chat relay icons
Chat rows render the inner rumor note of a NIP-17 message, but relay
attribution only landed there through narrow windows, so accepted relays
often never showed as icons:

- Duplicate deliveries were stranded on the wrap: a gift wrap re-delivered
  by a second relay hit the duplicate branch of consumeRegularEvent, which
  tagged the outer wrap note only and never re-processed the event. Extract
  the OK-path drilling (wrap -> seal -> rumor) into
  LocalCache.addRelayToNoteAndInners and call it from both the OK
  confirmation path (markAsSeen) and the duplicate EVENT path, replacing
  CacheClientConnector's private copy.

- Cross-thread visibility: Note.event, Note.relays, Note.flowSet and the
  innerEventId of GiftWrapEvent/SealedRumorEvent are written by decrypt/index
  coroutines and read lock-free on relay socket threads; a stale read parks
  an acceptance on the outer envelope permanently. Mark them @Volatile.

- Orphaned UI flows: RenderClosedRelayList/RenderAllRelayList and
  createMustShowExpandButtonFlows captured note.flow().relays.stateFlow once
  in remember/stateIn; MemoryTrimmingService.cleanObservers destroys the
  unobserved NoteFlowSet while the lifecycle is stopped, so resumed rows
  never saw another relay update. Wrap in a cold flow that re-resolves
  flow() on every collection start.

- Indexing latency: sent DMs waited for the ~1s newEventBundles batcher
  before the self-wrap was unwrapped and the message reached the chatroom,
  parking early OKs on the wrap. broadcastPrivately and
  sendNip04PrivateMessage now run the EventProcessor on the freshly
  consumed note immediately; the batched re-delivery is idempotent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YEiq1NMK3q12KGQ2yYhPEp
2026-07-10 22:29:41 +00:00
Claude e85de73e60 feat(concord): plane-wrapped reactions and replies (outbound)
Receiving reactions/replies already worked (they decrypt via channelRumors and
wire to their target Note by id). This adds the send side, which must NOT go
through the generic public/NIP-17 reaction path — a plaintext kind-7 would e-tag
the private rumor id onto public relays, and the NIP-17 path wraps to named
recipients, not the channel plane.

- ChannelChat.reaction/reply build kind-7/kind-9 rumors bound to channel+epoch
  (reaction e-tags the target; reply q-tags the parent)
- ConcordActions.buildChannelReaction/buildChannelReply wrap them on the plane
- Account.reactToConcordMessage + sendConcordChannelMessage(replyTo) publish the
  wrap with an instant local echo, factored through publishConcordWrap
- AccountViewModel.reactToOrDelete intercepts Concord notes (detected by the
  ConcordChannel gatherer) and routes to the plane-wrapped reaction
- ConcordChannelScreen wires onWantsToReply into the composer with a reply banner

Zaps already route correctly: a Concord message is an unsigned rumor, so the
existing isPrivateRumor() path forces a PRIVATE (NIP-57 encrypted) zap, same as
NIP-17 DMs. A fully on-plane nutzap (no public receipt) remains a future upgrade.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-10 21:20:07 +00:00
Claude e560df6edc feat(concord): add ConcordChannel model + LocalCache index
The channel model the shared chat UI renders, mirroring RelayGroupChannel:

- quartz ConcordChannelId(communityId, channelId): the stable channel address
  (analog of NIP-29 GroupId, but community-scoped rather than relay-pinned)
- commons ConcordChannel : Channel — name/voice/private from the folded Control
  Plane (no single relay-signed metadata event), relays() = the community relay
  set (a plane may be mirrored on several), membership from the authority
  resolver, canPost(), placeholderNote() for immediate Messages-list rows;
  updateFrom(state, relays, myPubKey) refreshes on each re-fold
- LocalCache: concordChannels LargeCache index + getOrCreate/getIfExists

Compiles across :quartz/:commons/:amethyst. Next: the decryption subscription
that folds inbound channel-plane wraps into these, then the Messages inbox hook.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-10 18:25:52 +00:00
Claude 88732d507c feat(concord): make joined-list a registered Event (13302) for cache/account use
Promote the kind-13302 joined-communities list to a proper Event subclass so the
Android LocalCache/Account layer can observe it the way RelayGroupListState
observes SimpleGroupListEvent (10009):

- ConcordCommunityListEvent : Event — createAddress (13302, pubkey, ""), a
  suspend create(signer, entries), and decrypt(signer); content stays NIP-44
  self-encrypted and carries each community's secrets
- register the kind in EventFactory so inbound events parse to the typed class
- extract encode/decode JSON helpers in ConcordCommunityList (shared by both)

Test: create -> the wire form hides the name, a JSON round-trip resolves to the
typed class via EventFactory, decrypt recovers entries, and the replaceable
address is (13302, pubkey, ""). Green on :quartz:jvmTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-10 17:36:49 +00:00
Claude 51494d5330 Merge remote-tracking branch 'origin/main' into claude/concord-quartz-amethyst-plan-0oy779 2026-07-10 17:05:56 +00:00
Claude 46a2652fc5 feat(concord): add direct invites (kind 3313)
Completes CORD-05: for a known npub, deliver the CommunityInvite as a NIP-59
giftwrap instead of a public bundle — a kind-3313 rumor sealed (kind 13) to the
recipient and wrapped (1059) with ["p", recipient] and a ["k","3313"] index tag
so recipients can query pending invites without decrypting every giftwrap.
Cannot be revoked (recipient holds the keys on arrival).

Reuses SealedRumorEvent + the giftwrap primitives. Tests cover round-trip to the
intended recipient (with p/k tags) and that strangers cannot open it. Green on
:quartz:jvmTest.

With this, the Quartz protocol layer covers CORD-01..07 end to end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-09 23:11:22 +00:00
Claude 5ea59ca137 feat(concord): add voice presence and blind-broker token (CORD-07)
- VoicePresence: kind-23313 join/left presence rumors bound to the channel/epoch
  and carrying the SFU identity + broker, with heartbeat/stale constants and a
  verifiedParticipants fold that renders an identity only when exactly one author
  claims it (contested identities stay unverified)
- ConcordBrokerToken: the NIP-98-style kind-27235 token request signed by the
  channel's derived voice signer key (its pubkey is the SFU room name), the
  'Authorization: Concord <base64(event)>' header, and the
  /.well-known/concord/av/<room> path

Voice key derivation (voice_signer/voice_media/voice_sender) already lives in
ConcordKeyDerivation. Tests cover presence round-trip, uncontested-only
verification, staleness, and that the broker token is signed by the voice-room
key. 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 23:08:07 +00:00
Claude 5669414de7 feat(concord): add invite bundle (33301) and full join flow
Completes the public invite path (CORD-05), pinned to Concord v2 (Armada
invite.ts):

- CommunityInvite: the bundle contents with exact snake_case field names
  (community_id, owner, owner_salt, community_root, root_epoch, channels[],
  relays, name, icon, expires_at, creator_npub, label) + ImagePointer/InviteChannel
- ConcordInviteBundle: build/parse the kind-33301 event (content =
  nip44(CommunityInvite, inviteBundleKey(token)); tags d="",vsk="6"; signed by a
  per-link signer), self-certification validate (owner+salt reproduce
  community_id), expiry check, and mintLink (fresh token + link signer -> bundle
  event + shareable URL)

End-to-end test: create a community, mint an invite link, a stranger parses the
URL, decrypts the bundle with the fragment token, validates the owner
commitment, reconstructs the root, and reads the genesis #general channel.
Wrong-token and forged-owner rejections covered. 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 23:04:46 +00:00
Claude f42f587ade feat(concord): add community creation factory and entity coordinates
Completes the "create a community" path (CORD-02 Genesis), pinned to Armada
(concord-v2 control.ts/community.ts):

- ConcordKeyDerivation: control/guestbook plane keys and the keyless entity
  coordinates — grantCoordinate = hkdf32(communityId, "concord/grant"||member),
  banlistCoordinate (ZERO32 id), inviteLinksCoordinate (creator)
- ControlEditionBuilder: assembles kind-3308 edition rumors (vsk/eid/ev/ep/vac),
  the inverse of ControlEdition.fromRumor
- MetadataEntity gains relays
- ConcordCommunityFactory.create: mints owner_salt + self-certifying community_id,
  an independent community_root, and two owner-signed genesis editions (metadata
  with eid=communityId, and a public #general channel) as plaintext-seal wraps on
  the Control Plane at epoch 0

Test creates a community, verifies the id commitment, opens the genesis wraps
(20014 seals, owner-authored), folds them into live ConcordCommunityState with a
#general channel and owner authority, and confirms one owner yields distinct
communities. 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:53:13 +00:00
Claude b96960db88 feat(concord): add private joined-communities list (kind 13302)
The NIP-51 analog for returning to signed-up Concord communities (CORD-05):

- ConcordCommunityListEntry: per-community credentials needed to re-derive planes
  on any device (id, owner, ownerSalt, current root + rootEpoch, past heldRoots,
  privateChannels keys, relays, cached name)
- ConcordCommunityList: build/parse the replaceable kind-13302 event, NIP-44
  self-encrypted so relays store only ciphertext, plus a cross-device merge that
  keeps the freshest root epoch per community

Channels are intentionally not listed — holding the root and folding the Control
Plane yields them. Tests cover self-encrypted round-trip, that only the owner can
decrypt, and epoch-wins merge. 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:48:46 +00:00
Claude 7c7608913d fix: never render a group reaction as the Messages row content
The Messages-list feed already filtered kind-9/1068/11/1111 content when
selecting a group's representative note, but the ChatroomEntry render fallback
still rendered ANY group-scoped note — including a kind-7 reaction lingering in
the in-memory list — as the group row, using the reaction's content and time.

Add a shared quartz helper `Event.isGroupChatContent()` and use it in both
places: the feed filter and the render fallback. A non-content group-scoped note
now falls back to the channel placeholder ("No messages yet") instead of showing
the reaction as the room's last message.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 22:39:15 +00:00
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 2eb7ff2ef2 Merge remote-tracking branch 'origin/main' into claude/armada-nip29-integration-lwqard
# Conflicts:
#	cli/tests/.gitignore
2026-07-09 21:48:04 +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
Claude cc17b29bc0 fix(graperank): stop dropping events, un-evict live-but-slow hosts, re-sweep new relays
Addresses correctness/perf issues found in the crawler + reachability audit:

- deadHosts permanent eviction (#1): an authority that accrued timeoutEvictStrikes
  before its first EOSE was evicted forever — clearTimeoutStrikes only zeroed the
  counter and could not un-evict, contradicting the "a host that ever produces is
  never evicted" invariant. Add a producedHosts set that isDead() consults, so a
  proven-productive authority is never treated as dead even if a concurrent strike
  from the 24-worker fan-out raced it into deadHosts.

- Parking-disabled event loss (#2): when parking is off (no bgScope, or
  parkTimeoutMs <= timeoutMs), a relay that streamed events but didn't EOSE in the
  fast window had its buffer dropped without persist() and reported count 0. Drain,
  persist, and return those events like the other two branches; strike only when
  nothing was delivered.

- Wide-sweep over-narrowing (#4): relayListDiscoverySwept excluded an already-swept
  straggler from the wide pass even though the wide net grows each round, so a 10002
  hosted only on a later-learned relay was never fetched. Gate the wide pass on the
  asked-relay set (wideRelaysSwept) instead: new users get the full net, older
  stragglers get only newly-appeared relays, no (user, relay) pair asked twice.

- Onion detection (#10): replace loose relay.url.contains(".onion") with
  RelayUrlNormalizer.isOnion() in isDead() and networkTypeOf(), fixing the
  foo.onionfake.com false positive and the store/crawler disagreement.

- rtt-open=0 semantics (#9): document that the crawler's reachable records use
  rtt-open purely as a liveness flag (0 = latency not probed), not a real 0 ms
  measurement, and must not be published as authoritative latency data.

deadHosts is deliberately still NOT persisted to the 24h reachability cache (#8):
a timeout eviction means "too slow under our fan-out this run", not "proven
unreachable", so persisting it would blacklist slow-but-live hubs across runs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
2026-07-09 20:43:38 +00:00
Claude aa5c8f0491 revert(relay): drop FrameDispatchStats — per-frame cost on the shared WS hot path
FrameDispatchStats stamped a ValueTimeMark on every relay frame and recorded a
contended atomic per frame in BasicOkHttpWebSocket — the WebSocket layer used by
the whole app, unconditionally, forever — to answer a one-time question that only
graperank --diagnose read. It served its purpose (proved the our-side dispatch
lag is ~200ms mean and the EOSE-wait is dominantly relay-side, so the crawler is
network-bound), but the ongoing per-frame Pair allocation + atomic contention on
every client's relay traffic isn't worth carrying. Revert the channel back to
Channel<String> and delete the stats holder. The diagnose-gated saturation ticker
and per-drain latency breakdown stay — they're crawler-local, off the hot path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
2026-07-09 19:52:42 +00:00
Claude 1f01407442 Revert "perf(graperank): adaptive idle-EOSE cutoff (--eose-idle-ms)"
This reverts commit e02f00384a.
2026-07-09 18:52:50 +00:00
Claude e02f00384a perf(graperank): adaptive idle-EOSE cutoff (--eose-idle-ms)
Measured: relays deliver their events in ~0.6s then sit ~4.6s (86% of drain wall)
before sending EOSE — mostly relay-side (our pipeline adds only ~200ms). So instead
of waiting the full 10s fast window then parking, close a drain that has delivered
>=1 event and then gone silent for eoseIdleMs, treating it as complete ("eose-idle").

awaitTerminalOrQuiescent: the idle timer arms only AFTER the first event, so a relay
merely slow to answer still gets the full timeoutMs and is never cut prematurely; a
still-streaming relay keeps resetting the window. eose-idle paginates if the page was
capped and clears timeout strikes (it delivered), but joins notAnswered (no clean
EOSE, so its missing authors are retried elsewhere). Off by default (eoseIdleMs=0),
CLI --eose-idle-ms, so it can be A/B'd against the plain fast window.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
2026-07-09 18:19:43 +00:00
Claude a5c2a8b2c1 revert(relay): frame dispatch back to Dispatchers.IO — dedicated pool regressed
The dedicated frame-dispatch pool (ea1093ad) made dispatch lag WORSE, not better:
mean 200ms→460ms, max 3.5s→5.5s, frames>1s 43k→76k. The pool was sized cores*2
(=8 here) vs Dispatchers.IO's 64 threads, so it cut frame-processing parallelism
~8x. Lesson: the our-side lag is dominated by per-connection serial decode
throughput / thread count, NOT cross-contention with the store's IO writes — the
experiment ruled that hypothesis out. Reverting to shared IO; keep FrameDispatchStats.
EOSE-wait is confirmed dominantly relay-side (200ms our-mean vs ~5s eose-wait).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
2026-07-09 18:19:43 +00:00
Claude ea1093adaf perf(relay): isolate WS frame dispatch onto a dedicated pool, off Dispatchers.IO
Measured on a GrapeRank crawl, frame decode/dispatch (per-connection consumer
coroutines) ran on the shared Dispatchers.IO — the same pool that runs the store's
blocking SQLite inserts. During event floods, frame coroutines queued behind those
inserts: mean 200ms and up to 3.5s of dispatch lag, with 43k frames waiting >1s in
our pipeline. That lag also skews the relay-idle/EOSE timing the crawler reads.

Give frame processing its own daemon thread pool (sized to a small multiple of
cores; decode is light + CPU-bound), shared across all connections. Frame delivery
stays prompt regardless of what the IO pool is doing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
2026-07-09 17:56:22 +00:00
Claude 4c20522d78 feat(graperank): frame-dispatch-lag metric to attribute EOSE-wait
Adds FrameDispatchStats: the lag between a relay frame arriving on the OkHttp
reader thread and our per-connection consumer coroutine (on shared Dispatchers.IO)
pulling it off the channel — pure our-side pipeline delay, relay send-timing
excluded. BasicOkHttpWebSocket stamps arrival before enqueue and records the lag
on dequeue; the crawler resets it at start and dumps it in the --diagnose summary.

Answers whether a drain's 5s gap between the relay's last event and its EOSE is
the relay being slow to SEND eose (low dispatch-lag) or our IO pipeline backing up
so the already-arrived eose frame sits queued (high dispatch-lag).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
2026-07-09 17:24:35 +00:00
Claude a545151a34 fix: reject non-32-byte pubkeys when decoding npub/nprofile
A Nostr pubkey is x-only, exactly 32 bytes, but NPub.parse/NProfile.parse never
checked the length — they hex-encoded whatever bytes the bech32/TLV carried. A
malformed npub/nprofile that some clients encode with the full 33-byte COMPRESSED
secp256k1 key (0x02/0x03 prefix) therefore round-tripped its 66-char hex straight
into a `p`/`q` tag via the quote/mention path, and a strict relay (relay29 /
pyramid.fiatjaf.com) rejected the whole group message:

  blocked: schema validation failed: tag[..]: invalid pubkey value
  '02977dcf…c3402' ... pubkey should be 64-char hex

We never generate compressed keys ourselves (Nip01Crypto.pubKeyCreate strips the
prefix byte); this is purely inbound malformed input. Enforce the 32-byte length
at the decode boundary so the bad entity never becomes a mention/quote tag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 17:24:03 +00:00
Claude fdd0788e7f feat(graperank): saturation + latency instrumentation (--diagnose)
Answers "are we resource-bound or waiting on relays" without a profiler:
- progress ticker gains "Nw/CAPw" (drain workers busy vs drainConcurrency) and
  "N rl" (rate-limit responses so far) — a rarely-full pool means the producer or
  the relays are the limit, not concurrency; a climbing rl count is the external
  ceiling that made concurrency 60 backfire.
- crawl-end "latency breakdown": splits each drain's wall into time-to-first-event
  vs EOSE-wait-AFTER-the-relay's-last-event, and reports the % of drain wall spent
  waiting for EOSE after the relay was already done, how many drains blew the fast
  window and parked, and total rate-limit hits. A high EOSE-wait % is the direct
  case for a shorter/adaptive fast window over more concurrency.

All gated on config.diagnose; zero cost on a normal run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
2026-07-09 16:51:28 +00:00
Claude 99e9708a3b fix(graperank): flush only observed relays; rename to GrapeRankCrawler
Two changes:

1. The reachability flush re-wrote the SEEDED known-dead relays with a fresh
   created_at every run, refreshing their TTL without a re-probe — so a relay
   marked dead once (and thereafter skipped, never re-dialed) would stay
   blacklisted forever as long as crawls kept running, defeating the TTL's
   re-probe. Stats.deadRelays now reports only relays actually dialed this run
   (deadRelays - knownDeadRelays); seeded records keep their original timestamp
   and age out on schedule so the next run re-probes them.

2. Rename GrapeRankDataCrawler -> GrapeRankCrawler (file + all references).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
2026-07-09 16:40:54 +00:00
Claude 8ced11b6b0 feat(graperank): share dead-relay knowledge via the NIP-66 reachability cache
Wire RelayReachabilityStore into the crawler and the WoT updater so liveness is
shared across procedures and runs instead of each rediscovering dead relays.

- OperatorKeys.monitorKey(): a dedicated machine monitor identity derived from the
  operator master (domain "relay-monitor:"), independent of any account — the
  30166 records are published under this, not the observer key.
- Context.reachability: a RelayReachabilityStore over the shared store, signed by
  the monitor key.
- Crawler: Config.knownDeadRelays seeds deadRelays before the run; Stats now
  returns the final dead/live sets. GrapeRankCommand seeds from snapshot().dead
  and flushes the crawl's verdicts back via reachability.record().
- Updater: Config.knownDead skips proven-dead relays from the reconcile plan — a
  dead relay cannot serve its authors, so reconciling it only burns a timeout.
  Live author-advertised relays are always synced.

All behind --no-reachability-cache. TTL'd (24h), so a recovered relay is retried
once its record ages out — a "skip for now", never a permanent ignore, keeping
the outbox rule that every live advertised relay is tried.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
2026-07-09 16:30:45 +00:00
Claude b5e0ef53e2 feat(nip66): RelayReachabilityStore — dead-relay cache backed by kind:30166
A durable, shareable relay-reachability cache backed by the EventStore as NIP-66
kind:30166 Relay Discovery events, so the crawler, the WoT updater, and future
runs share liveness knowledge instead of each rediscovering dead relays from an
in-memory set wiped at process exit.

- 30166 is addressable by its d-tag (relay URL) → one replaceable status slot per
  (monitor, relay), with created_at giving a free TTL.
- Reachable → 30166 with rtt-open; dead → 30166 without (NIP-66 has no explicit
  offline field; liveness is inferred from a fresh successful open). Live wins
  over dead within the TTL, so third-party monitors' 30166 can be ingested.
- snapshot() loads the fresh set once (not a per-request hot-path query); record()
  flushes a run's findings. A relay is only skipped for the TTL, never permanently
  — consistent with the outbox rule that every advertised write relay is tried.

Reuses the existing RelayDiscoveryEvent. jvmTest covers record/reload,
live-overrides-dead, TTL expiry, and .onion→Tor network tagging.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
2026-07-09 16:23:20 +00:00
Claude 7ee962ce21 Merge remote-tracking branch 'origin/main' into claude/graperank-sync-crawl-1n05im 2026-07-09 14:00:26 +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
Claude f1b5be9fdf perf(graperank): stop re-discovering outboxes; co-fetch kind:3 on indexers only
Round-8 profiling showed the crawl re-querying the same never-had-a-10002
users' outboxes every round they recirculated — ~144k slow kind:10002 drains
(p50 17.4s) against a static discovery set, dragging the round to ~18 users/s.

1. ensureRelayLists guards with `relayListDiscoverySwept`: each user's outbox
   discovery runs once. The discovery relay set is static, so a second sweep of
   a user still lacking a 10002 cannot find one the first missed.

2. The discovery REQ to the bounded INDEXER set co-fetches [10002, 3]: the
   outbox lookup already pays the round-trip and an indexer holding a user's
   10002 often holds their kind:3, so we harvest the contact list as a cheap
   byproduct. The wide "every live relay" completeness sweep stays 10002-ONLY —
   co-fetching kind:3 across thousands of relays downloaded the same big contact
   lists repeatedly and inflated the fire-and-forget bgScope sweep the finishing
   drain waits on (measured +260s at hop-3; the indexer-only co-fetch keeps
   coverage flat at baseline speed).

3. harvestFromStore folds any already-stored kind:3 into the graph at Phase-A
   time so Phase B never re-drains a list we hold (also speeds re-runs).

Verified same-session hop-3: pre-fix 685s / narrowed 690s / wide-co-fetch 945s,
coverage 91.74% across all.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
2026-07-09 11:53:31 +00: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