Removes the CONTROL/COMMUNITY_LIST/INVITE_BUNDLE constants from ConcordKinds
now that each has a dedicated Event class. Callers reference
ControlEditionEvent.KIND, ConcordCommunityListEvent.KIND, and
ConcordInviteBundleEvent.KIND directly, matching the nip88Polls per-kind
event convention.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
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
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
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
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
Findings from a full NIP-13 review:
- The spec recommends updating created_at while mining. Queue jobs now
re-stamp the template to "now" when a worker picks them up (a post can
wait behind other jobs, and a job restored after process death could be
hours old); the restorer does the same. Scheduled posts are exempt —
their future created_at is intentional. Anonymous posts re-stamp before
mining against the throwaway key.
- PoWTag.assemble(nonce, null) serialized the literal string "null" as
the third tag entry; a missing commitment now omits the entry entirely.
- New tests: PoWTagTest pins the NIP-13 example tag shape and the
no-commitment round trip; a queue test asserts the created_at
re-stamp at mining start.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
Implements user-facing PoW publishing (#3317) on top of the existing
quartz miner:
- quartz: PoWMiner.run gains a cooperative isActive cancellation hook
(checked every ~VALID_BYTES^2 hashes); PoWNostrSigner decorator mines
kind-scoped templates pre-signature so it composes with every signer;
GiftWrapEvent.create/NIP17Factory can mine the outer ephemeral-key
wrap (never the seal/rumor); NostrSignerWithClientTag exposes
prepareTags so mining runs over the final tag set.
- commons: PoWPublishQueue (FIFO, capped worker pool on
Dispatchers.Default, per-job cancel, in-memory only — unmined posts
are lost on process death, logged) and PoWPolicy (kind-group
categories with a hardcoded NEVER list: auth, zap requests, NWC and
bunker RPC, HTTP/Blossom auth, drafts, metadata and lists, OTS).
- amethyst: per-account synced settings (difficulty Off/16/20/24/28 or
custom, per-category checklist) in Compose Settings; Post enqueues the
template and returns immediately; reactions, reposts, reports, private
notes, DMs and long-form route through the same shouldMine gate at
their existing choke points; per-post PoW override chip in the
composer options row; "Mining proof of work… (N in queue)" phase with
per-job cancel in the broadcast banner.
Scheduled posts and anonymous posts mine against the correct key
(scheduled posts skip mining in v1); the client tag is applied to the
template before mining so signing never invalidates the nonce.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
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
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
- 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
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
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
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
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
- 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
- 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
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
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
- 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
- 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
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
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
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
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.
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.
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
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
Relay-signed kind-39000 has no author-of-a-follow, but the people dimension
still exists: a follow may be the relay signing key, a group admin (39001), or
a member (39002). Discovery now resolves each top-nav filter into a per-relay
GroupDiscoveryConstraint instead of collapsing every filter to the same REQ.
quartz:
- GroupMetadataEvent / EditMetadataEvent: build + read #t (topics) and #g
(geohash, mip-mapped so a coarser followed geohash still matches). Interop
tests for parse/build round-trips.
amethyst:
- dal/RelayGroupDiscoveryFeedFilter: sealed GroupDiscoveryConstraint
(AllGroups / ByPeople / ByHashtags / ByGeohashes / AnyOf) + toGroupConstraints()
mapping each IFeedTopNavPerRelayFilterSet to per-relay constraints, with
matches() covering the relay-key/admin/member people paths and topic/geo tags.
Unit tests.
- Directory REQ narrows to 39000 #t/#g for topic/geo filters, broad directory
otherwise (people match needs the rosters).
- ViewModel keys the feed on the constraint map and re-scans on any directory
event (metadata OR roster) so late-arriving admins/members surface groups.
- Create/edit form gains a Discovery section (topics + geohash) threaded through
Account.createRelayGroup/editRelayGroupMetadata and EditMetadata.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
The drain classifier had two "act on it" verdicts, HARD (drop now) and TRANSIENT
(strike a few times, might clear). Re-probing hop-8's failed relays fresh showed
the TRANSIENT bucket almost never clears: 503 Service Unavailable 0/12 reachable,
502 Bad Gateway 3/15, connection-establishment failures 0/30; the codes that were
alive (402/403) are gated and will never serve us, and 200 isn't a relay. So the
extra dials TRANSIENT bought were spent on hosts that stay dead for the run.
Collapse to a single DEAD verdict, dropped on the first strike, and carve out the
only two connect failures that genuinely recover so they stay retryable (null):
a READ timeout (relay answered the handshake, slow — 67% reachable fresh, kept on
the clear-on-success authority-strike path) and an HTTP 429 rate-limit (alive,
4/4 reachable — retrying spaced by the limiter is how we get its data). Removes
the now-unused relayStrikes map, MAX_DEAD_STRIKES, and the HARD/TRANSIENT merge.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
A connect failure and a read timeout were both treated as "busy, retry" and
took three strikes to drop. Re-probing hop-8's failed relays fresh, outside the
crawl, showed the two are not alike: relays that failed to ESTABLISH a
connection (connect timed out, refused, unroutable, or the proxy couldn't tunnel
the CONNECT) were 0/30 reachable — genuinely dead — while relays that hit a READ
timeout were 12/18 (67%) reachable, alive but overloaded by the crawl's fan-out
(user.kindpag.es among them).
So classifyDrainFailure now returns HARD for connection-establishment failures
(one strike drops them instead of burning two more dials on a dead host), while
a read/generic timeout still returns null and stays on the patient,
clear-on-success timeout-strike path so live-but-slow relays we need are not
wrongly evicted. Mid-stream resets stay TRANSIENT. Adds DrainFailureTest, which
the classifier previously had none of.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
Reject a single-character group id (except the default `_`) so a possessive
glued to a bare relay URL — `wss://relay.damus.io's uptime` — no longer
linkifies group "s". Real ids (relay29/Wisp/0xchat) are all longer.
Adds coverage proving only genuine ws/wss relay URLs are peeked: apostrophes
after http, nostr:, blossom:, email and bech32 tokens never become group links;
plus ws:// (insecure), second-apostrophe boundary, and multi-link cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
Recognise the de-facto `<relay>'<groupId>[?code=<code>]` NIP-29 group invite
link format used by Wisp and 0xchat, both inside rendered note content and as
an external deep link, so tapping one opens the group.
The URL detector correctly stops a host at the apostrophe (host names can't
contain `'`), so the group id is torn off before classification. Rather than
loosen the shared URL grammar — which would swallow prose possessives like
`example.com's` — group links are recovered by peeking just past each relay
URL the detector already found. This is cache-miss-only and costs nothing on
notes without a `wss://` link.
- quartz: GroupInviteLink.parse / suffixLength (+ tests)
- commons: Urls.groupLinks, UrlParser peek, RichTextParser plumbing
(atomic span through fixMissingSpaces, new RelayGroupLinkSegment) (+ tests)
- amethyst: ClickableRelayGroupLink renderer + uriToRoute deep-link branch
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
classifyDrainFailure deliberately treats every timeout — connect timeout or
park idle-cut — as "busy, retry" and never dead, so a relay that connects but
never answers a REQ gets re-routed through every straggler's outbox, every
round, each visit burning the full timeout + park window for zero data. The
outbox model makes this worse: one dead server (e.g. filter.nostr.wine) is
advertised as hundreds of distinct per-user path URLs, so a per-URL counter
never reaches a threshold on any single one.
Count unproductive-timeout strikes per relay AUTHORITY (host[:port]) and evict
the whole host after Config.timeoutEvictStrikes (default 3; CLI --timeout-evict,
0 disables). Any clean EOSE or delivered event clears the authority, so only
never-productive hosts are evicted; a multi-path relay where some paths are
slow but others deliver stays live. Authority is host-only and never folds a
filter. subdomain into its parent, so an open bare host is untouched when its
sibling filter host is shed. Purely behavior-driven — no NIP-11.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
Two compatibility gaps found analyzing nostrord (a NIP-29 client):
- Thread titles: NIP-7D (and Amethyst) use a `title` tag, but nostrord
writes/reads `subject`, so neither showed the other's thread titles.
ThreadEvent.title() now reads `title` OR `subject`; we still emit only the
spec-correct `title`.
- Joined-groups list (kind 10009): Amethyst wrote memberships as NIP-44
private items, but both reference clients (Flotilla, nostrord) store — and
nostrord only READS — public `["group", id, relay]` tags, so an Amethyst
user's groups were invisible to them. follow() (and the amy CLI) now write
public tags. NIP-29 membership is already public via the relay's kind-39002
list, so this loses no real privacy; reads still merge any legacy private
items so existing lists keep working.
Tests: read title from title/subject (title wins; we emit title only); public
group is a plain tag and still read through the cache; a mixed public+private
list reads as both.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
Replying to a NIP-29 group thread went through the shared NIP-22 comment
composer, which built a plain kind-1111 comment with no group `h` tag and
broadcast it to the author's outbox. Such a reply is not group content: the
host relay rejects it and no other member — or other NIP-29 client like
Flotilla — ever sees it, and for a private/closed group it leaks to
unrelated relays.
Both fixes are tightly guarded on the replied-to event being group-scoped,
so ordinary comments are untouched:
- CommentPostViewModel inherits the group's `h` tag from the event being
replied to (covers replies to the kind-11 root and to nested 1111
comments — both route here).
- The reply is published only to the group's host relay (the relay the
thread was seen on) via signAndSendPrivatelyOrBroadcast, instead of the
outbox-computing broadcast — so it reaches the group and never leaks.
Verified: quartz test builds the reply as the composer does
(CommentEvent.replyBuilder { hTag } over a kind-11 root) and asserts it is a
1111 carrying the group `h` tag and referencing the thread.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
Fixes found in a full audit of the NIP-29 relay-groups feature across
quartz/commons/amethyst/cli.
Correctness (app):
- Own group messages never appeared in the timeline until an app restart:
the optimistic send is consumed with a null relay, so attachToRelayGroup
bailed on the relay==null guard, and the host relay's echo (new==false)
was skipped by the "only attach when newly consumed" gate. Attach now runs
on every arrival, gated on the note being loaded, and the null-relay case
attaches to the already-open channel(s) for that group id. Also avoids the
wrong-relay phantom by only fabricating a channel from real provenance.
- Roster subscription was frozen after an in-place join/leave (state keyed
on the stable account, never re-derived); it now invalidates on every
liveRelayGroupList change, so a fresh join's 39002 admission is fetched.
- membershipOf demoted a 39001 admin with an empty/unknown role to MEMBER,
hiding moderation; presence in the admins list now means at least MODERATOR.
- Members roster showed permanent truncated-hex names (one-shot
getUserIfExists cached null); uses checkGetOrCreateUser so UsernameDisplay
fills in when kind:0 arrives.
Protocol / data:
- GroupTag had no value equality → joined-group Sets never deduped and the
StateFlow re-emitted on every identical re-arrival. Equality is now the
(id, relay) pair, excluding the cosmetic name.
- create/edit emitted non-canonical ["public"]/["open"] status tags; NIP-29
flags are presence-only, so only private/closed are emitted when set.
- Metadata/member/admin supersede guards use <= so an equal-createdAt
duplicate isn't reprocessed (first-arrival wins); updatedMetadataAt is now
private-set. Relay-group channels are now included in the prune loops.
CLI:
- join/leave/create updated the kind:10009 list from a network-only drain;
a slow/empty fetch could publish a fresh list containing ONLY the new
group, wiping the rest. Now reads the local store (source of truth) too.
- edit re-asserted both visibility axes from flag presence, so --closed on a
private group leaked it public. It now reads current 39000 and merges,
with --public/--open counter-flags; only the specified axis changes.
- create now tracks the new group in kind:10009 (parity with join/Android).
UI polish:
- Invite dialog no longer mints a 9009 for open groups and won't copy a code
it never displayed. Browse "popular" list normalizes URLs before filtering.
Tests: GroupTag identity, unknown-role-admin-moderates, equal-createdAt
no-resupersede added; all quartz+commons NIP-29 suites and the amy
relaygroup harness pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
Audited Quartz's nip29RelayGroups package against every event and tag
Armada (gitlab.com/soapbox-pub/armada) creates and reads, and closed the
gaps so Quartz can round-trip all of them.
- GroupMetadataEvent (39000): fix flag parsing. `private`/`restricted`/
`hidden`/`closed`/`livekit` are single-element presence tags, but the old
accessors used hasTagWithContent (requires a value) AND conflated
restricted<->closed and hidden<->private with wrong defaults (an open
public group parsed as private+restricted). Now each flag is a plain
presence check. Add hasLivekit() and supportedKinds(), extend GroupStatus
with RESTRICTED/HIDDEN/LIVEKIT, and support supported_kinds in build().
- Add GroupParticipantsEvent (kind 39004, LiveKit AV presence) and register
it in EventFactory + KindNames.
- Add TagArray.hasTagName() presence helper in nip01Core.
- Add GroupScope.kt: hTag() builder + Event.groupId()/isGroupScoped()
readers so kind 9 (chat), 11 (thread), 7 (reaction), 1068, 1111 etc. can
be group-scoped via the `h` tag and read back — NIP-29 reuses these
carriers rather than defining new content kinds.
- Add Nip29ArmadaInteropTest covering parse + build for every event with
Armada's exact tag shapes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
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.
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.
The GrapeRank engine, TrustGraph (compact int-CSR) and TrustGraphBuilder
are pure Nostr-social-graph computation over HexKeys — no UI, no Compose,
and no commons-only dependency. They're a utility for implementing the
NIP-85 rank assertions quartz already models, so they belong in quartz
rather than commons. Move commons/wot -> quartz experimental/graperank
(package com.vitorpamplona.quartz.experimental.graperank), including both
commonTest suites, and repoint the CLI import. TrustGraphBuilder was already
protocol-agnostic (takes HexKey lists; the caller does the event->edge
extraction), so nothing had to change but the package. Makes the algorithm
reusable by the Android app for spam/trust filtering without pulling in
commons.
Add Hex.toLong64/toLong128/toLong256 (plus the shared readLong helper) to
pack the first 64, 128 or 256 bits of a hex string into a single Long, two
Longs or four Longs. Big-endian, allocation-light, branch-free — 16 table
lookups and shifts per word. Useful as cheap map/set keys or bucket hashes
for 32-byte event ids and pubkeys without decoding to a ByteArray.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019CU1wR6NvQmdmNNsPe9GuN
- Use the app's cached stringRes helper instead of raw stringResource,
matching the dominant convention in ui/note/types
- Drive the icon animation from the Compose frame clock (withFrameMillis)
instead of a delay loop, so the ticker suspends whenever the composition
stops drawing rather than waking the main dispatcher 4x/sec from the
back stack
- Drop the unconsumed memoryCardId/blockState/blockHash accessors; the
tag schema stays documented in the class KDoc
- Document the frames arrays as frozen: mutating them in place would
silently break the @Immutable skip contract; build a new instance
to change pixels
- Replace the API-29-deprecated Bitmap.createBitmap(IntArray, ...)
overload with createBitmap(w, h, config) + setPixels