Commit Graph
2512 Commits
Author SHA1 Message Date
Vitor PamplonaandClaude Opus 4.8 28b93132a3 feat(concord): read + render CORD-02 §6 encrypted community icon/banner
Concord community icons never showed (robohash instead), and the community
name silently fell back to the invite name. Root cause: the icon/banner are
CORD-02 §6 **encrypted media** — the metadata entity carries an
`ImagePointer` object `{url,key,nonce,hash}` (AES-256-GCM ciphertext at
`url`, decrypted with `key`/`nonce`, `hash` = SHA-256 of the plaintext) —
but `MetadataEntity.icon` was typed `String?`. An object where a String is
expected fails the whole entity's decode, so metadata came back null: no
icon, and the name dropped to the entry fallback. Matches the Concord v2
reference client (Armada `concord-v2/lib/{types,image}.ts`).

- Promote `ImagePointer` to a shared CORD-02 type (was invite-only) and give
  it `decryptOrNull` (AES-256-GCM via the existing `AESGCM`, verifying the
  plaintext SHA-256 — a swapped blob fails closed).
- `MetadataEntity.icon`/`banner` are now `ImagePointer?`, so the entity (and
  the community name) decodes. `ConcordChannel` carries the pointers.
- `rememberConcordImageModel` resolves a pointer for the avatar: a plain-URL
  pointer (Amethyst's own form) passes through; an encrypted one is fetched,
  decrypted, verified, cached to disk, and rendered — else the robohash. Wired
  into the Concord hub avatars and the Messages-tab community chip.
- Amethyst's create/edit still take a URL and wrap it as a url-only pointer;
  authoring encrypted images (encrypt + upload) is a follow-up.

Adds ImagePointerTest: Armada-shape object decode, decrypt round-trip, and
fail-closed on a tampered hash.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 17:16:12 -04:00
Vitor PamplonaandClaude Opus 4.8 0e485349e3 fix(relay-auth): re-authenticate on an auth-required CLOSED (NIP-42)
Concord channels loaded empty because the channel-plane REQ was refused
until the relay happened to re-issue an AUTH challenge (~27s later, or
never on relays that don't re-challenge).

A channel's derived stream key only exists after the community's Control
Plane folds, but by then the connection's initial NIP-42 AUTH already ran
with just the control key. `RelayAuthenticator` only re-authenticated on a
fresh `AUTH` message and ignored `auth-required` CLOSED frames — so the
newly-revealed channel keys were never sent.

NIP-42 says the client must store the connection's challenge and reuse it
"in response to the auth-required CLOSED message". Do that: remember the
last challenge per relay, and on an `auth-required:` CLOSED re-run the
sign/send pass with it. `saveAuthSubmission` dedups by (pubkey, challenge)
so only not-yet-authed identities (the folded-in keys) are sent — a no-op
once they all are, so no loop. A burst guard skips re-signing while an AUTH
is already in flight (syncFilters re-drives the REQ when it settles), and
the re-auth is non-interactive: it re-sends only already-approved
identities (ledger-ALLOW accounts + stream keys) and never raises a prompt,
so it can't drag a bystander account onto a paid relay.

Adds an `interactive` flag to the signing callback and a
RelayAuthenticatorReauthOnClosedTest covering reuse, loop-safety,
burst-coalescing, and the non-auth-required CLOSED no-op.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 14:03:37 -04:00
Claude 648e9f427a feat(concord): wire CORD-06 refounding for real member removal
Ban is only a soft removal — the member still holds the room key and can
decrypt everything; clients just decline to show their posts. This wires
CORD-06 Refounding: the hard removal that rotates the community_root so a
removed member's key stops working for anything sent afterwards.

Quartz:
- ConcordKeyDerivation: baseRekeyAddress / channelRekeyAddress (the rekey
  stream addresses) and epochKeyCommitment (prevcommit, CORD-02 A.5).
- ConcordRekey: signer-based blobForSigner / findNewKeyWithSigner so a
  bunker account opens its blob with a single nip44Decrypt.
- ConcordRefounding: compactControlPlane (re-wrap each head edition's
  original plaintext seal under the new root, preserving signatures),
  buildBaseRekeyWraps, build, findNewRoot. OpenedStreamEvent now carries
  the inner seal for compaction. ConcordRefoundingTest.

Commons:
- ConcordActions: guestbookPlane / nextBaseRekeyPlane, buildGuestbookJoin /
  guestbookMembers, buildRefounding, openBaseRekey.
- ConcordCommunitySession folds the Guestbook plane into members (the
  recipient set), buffers inbound base-rekey wraps, exposes controlPlaneWraps,
  and AUTHs to + subscribes the Guestbook and next-epoch base-rekey planes.
- ConcordSessionRegistry.sync rebuilds a session when its entry's root/epoch
  changed; ConcordSubscriptionPlanner.auxiliaryPlaneSubs REQs the new planes.

Amethyst:
- Account announces a Guestbook JOIN on create/join; refoundConcordCommunity
  (owner / BAN-holder) bans + rolls + publishes + persists; drainConcordRekeys
  adopts an inbound rotation from an authorized rotator; adoptConcordRoot
  persists the new root (prior kept as a HeldRoot) and re-seeds the new epoch's
  Guestbook, guarded against double-adopt.
- AccountViewModel.removeConcordMember; ConcordMembersScreen "Remove from
  community" action + confirmation dialog.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-13 12:58:36 +00:00
Claude 025d63672f feat(chat): dual-mode replies (inline + minichat) — foundation, Concord composer, shared chip
Adds the two-way reply model: an INLINE reply stays in the timeline (native chat
message referencing its parent), a MINICHAT reply is a kind-1111 NIP-22 comment
pulled into a thread opened from the parent — matching Armada.

Foundation (quartz/commons):
- ReplyMode enum (INLINE default, MINICHAT).
- ChannelChat.inlineReply / ConcordActions.buildChannelInlineReply restore the
  kind-9 q-tag quote path alongside the existing kind-1111 ChannelChat.reply.
- ChannelFeedFilter excludes kind-1111 CommentEvents from the chat timeline (they
  belong in the minichat), so inline replies stay and thread replies move aside.
- observeNoteMinichatReplyCount: local count of a message's kind-1111 replies.

Concord composer + send (amethyst):
- ConcordNewMessageViewModel gains a replyMode state + toggle; the composer shows a
  "In chat" / "In thread" toggle beside the reply preview.
- Account.sendConcordChannelMessage routes MINICHAT to kind-1111, INLINE to kind-9
  quote, fresh post to kind-9 message.

Shared row chip (all chat types):
- ChatMessageCompose's action row shows an "N replies" chip when a message has
  kind-1111 thread replies; tapping opens the thread. Wired to the thread view for
  now; a chat-styled minichat screen and NIP-28/NIP-29 loading follow.

Plan: amethyst/plans/2026-07-12-dual-reply-minichat.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-12 20:40:29 +00:00
Claude 8065b045e4 fix(concord): build thread replies as kind-1111 NIP-22 comments, not kind-9 quotes
Concord replies were kind-9 chat messages carrying a `q` tag. In the Concord
model (matching Soapbox Armada) a `q` on a kind-9 is an inline *quote*, which
clients deliberately keep OUT of threads — a real thread reply is a kind-1111
NIP-22 comment. So our replies rendered (and were sent to Armada) as inline
quotes, never grouping into a message's thread.

ChannelChat.reply now builds a CommentEvent via CommentEvent.replyBuilder: the
uppercase K/E/P tags pin the immutable thread root and the lowercase k/e/p tags
point at the immediate parent (root inherited when the parent is itself a
comment, so the root is stable at any depth), plus the same channel/epoch binding
every Chat Plane rumor carries. This is byte-compatible with Armada's
buildV2CommentTags, so replies thread correctly in both directions. The read path
already accepts these (they carry the binding, and consumeConcordRumor handles
CommentEvent), so incoming Armada thread replies now land bound to their channel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-12 20:06:20 +00:00
Claude 60475c10c0 feat(concord): NIP-42 AUTH as the derived plane stream key (CORD-01 §4b)
Concord relays gate a plane's kind-1059 wraps behind NIP-42 and serve them only
to a connection authenticated AS the plane's derived stream key — a Concord wrap
is authored by the stream key and p-tagged to a throwaway ephemeral key, so the
member is neither author nor recipient. Amethyst authenticated only as the user,
so every plane REQ was refused ('auth-required: all authors must be
authenticated') and no channel or message ever loaded, even though the community
list (kind 13302) folded correctly.

Amethyst's relay-auth plumbing already supports multiple identities per
connection: RelayAuthenticator sends one AuthCmd per event in the list its
provider returns, deduped on (pubkey, challenge). This wires the derived stream
keys into that provider:

- ConcordCommunitySession.streamKeys() exposes control + folded-channel GroupKeys.
- ConcordSessionManager.streamAuthSecretsFor(relay) returns the stream secret keys
  a NIP-42 challenge from that relay must be answered with (relay-scoped to each
  community's own relays — the same scope the plane subscription uses).
- AuthCoordinator signs one kind-22242 per stream key locally (raw KeyPair via
  NostrSignerSync — never the account signer, never exposing user identity), and
  attaches them to every challenge independent of the user-auth policy. On auth
  success the existing syncFilters re-fires the previously-refused plane REQ.
- RelayAuthStatus LruCaches widened 10 -> 200 since one connection now
  authenticates as the user plus many plane keys (control + channels).

Mirrors Armada's streamAuth.ts (sign one AUTH per scoped stream key, accumulating
on the connection). Verified live on-device by the debug pass: authing as the
derived stream key returns the control-plane wraps that were previously refused.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-12 01:21:58 +00:00
Claude 3cac9077ec refactor(concord): delete ConcordKinds; kinds live on their owning classes
Removes the last catch-all ConcordKinds constant object. Each remaining kind
now lives on the class that owns it:
- wrap/seal kinds were already ConcordStreamEnvelope.KIND_WRAP/SEAL_* — callers
  (ConcordActions, ConcordSubscriptionPlanner) reference those directly.
- guestbook join/leave (3306) and kick (3309) become Guestbook.KIND_JOIN_LEAVE /
  KIND_KICK.
- direct-invite (3313), voice-presence (23313), and rekey (3303) inline their
  literals on ConcordDirectInvite/VoicePresence/ConcordRekey.

The unused edit/webxdc/typing/snapshot/invite-list/ephemeral-wrap constants are
dropped; they will own their literals when those events get dedicated classes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-11 15:12:32 +00:00
Claude 9c71034dd2 Merge remote-tracking branch 'origin/main' into claude/concord-quartz-amethyst-plan-0oy779 2026-07-11 15:04:57 +00:00
Claude 0015b39085 fix(concord): read/write the kind-13302 list in Armada's wire format
Amethyst serialized the joined-community list (kind 13302) as a bare JSON
array of flat camelCase entries, so it could not parse the document Soapbox
Armada actually publishes — a community created in Armada never appeared in
Amethyst even after the 13302 reached a shared relay.

Rewrites the codec to Armada's communityList.ts shape:
{ entries: [ { community_id, seed: JoinMaterial, current: JoinMaterial,
added_at } ], tombstones: [ { community_id, removed_at } ] }, where
JoinMaterial is the snake_case per-snapshot key bundle (community_id, owner,
owner_salt, community_root, root_epoch, channels[], relays, name, held_roots?,
refounder?). Hydration prefers current over seed; liveness is derived from
tombstones (an entry is dropped only when removed strictly after it was added).
New create/join entries now stamp added_at (ms) so the liveness tiebreak works.

Adds interop tests that decode a real Armada document and exercise the
tombstone-after-add drop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-11 14:56:30 +00:00
Claude 127d959c8d refactor(concord): community-list, control, invite-bundle events own their kind literals
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
2026-07-11 14:56:28 +00:00
Claude 0e1a14a62d fix: don't drop whole kind-0 profiles over one mistyped field
Clients in the wild publish structurally wrong values in profile
metadata — e.g. "nip05":{} — and the strict field serializers made
JsonMapper throw, so contactMetaData() returned null and the entire
profile (name, picture, about…) was discarded.

Generalize the BirthdayTolerantSerializer precedent: string fields now
use TolerantStringSerializer (accepts any JSON primitive, ignores
objects/arrays/null) and the bot flag uses TolerantBooleanSerializer,
so a single malformed field is skipped instead of being fatal.

Non-JSON content (e.g. "Relay initialized") remains unrecoverable and
still parses to null; a test pins that it does so without throwing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSCGp6KgcnaepEMbxNroYe
2026-07-11 14:34:50 +00:00
Vitor PamplonaandGitHub 8e38ea6607 Merge pull request #3533 from vitorpamplona/claude/amethyst-miner-performance-0rw1dn
Parallelize PoW mining with multi-worker nonce search
2026-07-11 09:54:01 -04:00
Claude 5683c74629 perf(amethyst): mine on half the device's cores in the Android app
Each PoW job now races PoWPolicy.minerWorkers(cores) = cores/2 parallel
searches (at least 1) instead of a single thread, so a post on an 8-core
phone mines ~4x faster while the other half of the cores stays free for
the UI, and the total CPU budget matches the old 2-job x 1-thread pool.

- PoWPolicy.minerWorkers(availableProcessors) is the single definition of
  the per-job worker budget.
- PoWPublishQueue gains minerThreads (used by its built-in miner and
  exposed for custom mine lambdas); AppModules wires maxConcurrent = 1 +
  minerThreads = half the cores.
- PoWNostrSigner gains a workers param (default 1), covering reactions,
  reposts and reports via Account.miningSigner; the anonymous-post paths
  in the short-note and comment composers pass the same budget.
- Gift-wrap envelope mining stays single-threaded: the template
  conversion is a non-suspend hook inside the synchronous NIP-59 build.
- UI time estimates (settings picker, composer chip, broadcast banner)
  now benchmark at the queue's worker count via deviceHashesPerSecond(),
  and PoWEstimator caches one rate per worker count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhaF5scnAvhP9wr9R7bTWM
2026-07-11 12:13:32 +00:00
Claude bc365c8ca2 refactor(concord): invite bundle (33301) gets its own addressable event
Third refactor slice (plan: quartz/plans/2026-07-11-concord-event-classes.md).
The kind-33301 public invite bundle is now a proper addressable Event class:

- New cord05Invites/bundle/ConcordInviteBundleEvent (BaseAddressableEvent), with a
  build{} template that emits the ['d',''] + ['vsk','6'] tags via the standard DTag
  ext and the shared VskTag (ControlEntityKind.INVITE_LIVE). Registered kind 33301
  -> ConcordInviteBundleEvent in EventFactory so fetched bundles parse as the class.
- ConcordInviteBundle.build now signs ConcordInviteBundleEvent.build(...) with the
  per-link key; the raw TAG_D/TAG_VSK/VSK_LIVE constants are gone. Tag order (d, vsk)
  is preserved so the bundle event id / naddr are unchanged — the cord05Invites
  tests (bundle round-trip + join flow) pass.

Remaining invite kinds: 3313 direct-invite (rumor has empty tags; p/k index is on
the giftwrap) and 13303 invite-list (unimplemented stub) — tracked in the plan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-11 12:00:42 +00:00
davotoula cf95b07855 fix: document intentionally empty default no-op bodies 2026-07-11 11:12:20 +01:00
Claude dc2abc0e1c refactor(concord): control edition (3308) gets its own event package
Second refactor slice (plan: quartz/plans/2026-07-11-concord-event-classes.md).
The kind-3308 control edition now follows the nip88Polls per-kind shape:

- New cord04Roles/control/ package: ControlEditionEvent (the Event subclass +
  build{} template + typed accessors), TagArrayBuilderExt (vsk/eid/ev/ep/vac),
  TagArrayExt (readers), and tags/ classes VskTag/EidTag/EvTag/EpTag/VacTag with
  parse/assemble/isTag.
- ControlEditionBuilder.rumor now delegates to ControlEditionEvent.build +
  RumorAssembler; the raw stringly-typed tag construction and the
  ControlEdition.TAG_* constants are gone. Tags stay in the fixed vsk,eid,ev,ep,vac
  order so chain rumor ids are unchanged.
- ControlEdition.fromRumor reads the typed tags, preserving exact validation
  (present-but-malformed ep/vac still rejected; absent = genesis/owner).
- Register kind 3308 -> ControlEditionEvent in EventFactory so decrypted control
  rumors parse as the typed class.

The edition hash is over content fields (not tags), and all Concord tests —
ControlEditionTest, the fold/authority suite, and the join-flow round-trip — pass,
so the wire format and chain are preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-11 04:27:49 +00:00
Claude c2cbd602bc refactor(concord): chat plane reuses standard events + typed binding tags
First slice of the nip88Polls-structure refactor (plan in quartz/plans/
2026-07-11-concord-event-classes.md). A Concord chat rumor IS a standard Nostr
event plus a channel/epoch binding, so stop re-deriving it by kind number:

- New cord03Channels/tags/ChannelTag + EpochTag (typed, parse/assemble), plus
  TagArrayBuilderExt (channel/epoch/channelBinding) and TagArrayExt
  (concordChannel/concordEpoch/isConcordBoundTo) — the poll-package shape.
- ChannelChat.message/reply now build via ChatEvent.build{ channelBinding(...) }
  and assemble the template into a rumor; reaction reuses ReactionEvent.KIND. The
  minimal e/p/k reaction tags and q/p reply tags are kept byte-identical for Armada
  interop (guarded by the Concord round-trip tests, which pass).
- Drop the ConcordKinds.MESSAGE/COMMENT/REACTION/DELETE aliases (they shadowed
  ChatEvent/CommentEvent/ReactionEvent/DeletionEvent KINDs); callers use the real
  event KINDs. VoicePresence uses the new tag classes instead of bindingTags.

Remaining Concord-specific kinds (control 3308, invites, guestbook, rekey, voice,
seals/wrap) get their own per-kind packages in later slices per the plan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-11 04:05:07 +00:00
Claude ac98036a8e perf(pow): mine NIP-13 proof of work on all cores and drop per-hash allocations
The miner enumerates the nonce space deterministically (the random base is
overwritten before the first hash), so naively racing N copies of the search
duplicates the exact same candidate sequence N times. PoWMiner.mine() now
races workers over disjoint slices instead: each worker's nonce carries a
distinct fixed prefix while only the bytes after it are enumerated, making the
aggregate hash rate scale with cores (~3.8x on a 4-core box).

The hot loop also switches from sha256() to sha256Into() with a reused
32-byte buffer, so hashing no longer allocates per attempt.

amy wiring:
- `pow mine` and `post --pow` mine on all cores by default; `pow mine
  --threads N` overrides.
- `pow bench` measures the all-cores rate (what mining now uses, also the
  basis for expected_seconds) alongside a new hashes_per_second_single_core.
- PoWEstimator benchmarks with sha256Into to match the miner, and gains a
  workers overload that prices in cross-core contention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhaF5scnAvhP9wr9R7bTWM
2026-07-11 04:04:28 +00:00
Claude f18ba03a48 fix(concord): store the kind-13302 list replaceably so the hub populates
The Concord hub was empty even after the 13302 arrived: ConcordCommunityListEvent
extended plain Event, so LocalCache filed it by id and the dispatch when() had no
branch for it — it fell through to consumeRegularEvent. But ConcordChannelListState
observes the addressable note at Address(13302, pubkey, ""), which never received
the event, so liveCommunities stayed empty.

Make ConcordCommunityListEvent a BaseReplaceableEvent (kind 13302 is a NIP-01
replaceable — fixed empty d-tag, address = (kind, pubkey, "")) and add the
consumeBaseReplaceable branch next to the kind-10009 list, so it lands in the
addressable cache exactly like its NIP-29 sibling. The plane-wrap path (1059 ->
concordSessions.ingest in DecryptAndIndexProcessor) was already correct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-11 03:53:17 +00:00
Vitor PamplonaandGitHub 5c8801082a Merge pull request #3513 from vitorpamplona/claude/amy-graperank-crawl-perf-jhmx0x
GrapeRank crawl: connect once, wait once — 38% faster at hop-3; hop-8 31% faster at +15% lists / +47% users
2026-07-10 23:19:58 -04:00
Claude 8326f08f43 Merge remote-tracking branch 'origin/main' into claude/concord-quartz-amethyst-plan-0oy779 2026-07-11 01:51:00 +00:00
Claude 73c6bd6180 feat(concord): members roster + edit-metadata screens, redesigned create form
Adds the two management screens the NIP-29 relay groups have but Concord lacked,
plus a visual overhaul of the create screen:

- Members roster (ConcordMembersScreen): owner + role-holders + banned, each with
  avatar/name and owner/admin/banned badge; overflow menu promotes/demotes (owner)
  and bans/unbans, gated on the viewer's authority. New AuthorityResolver.roleHolders()
  and bannedMembers() accessors expose the privileged roster (membership is otherwise
  key possession, so there is no silent-member list).
- Edit metadata (ConcordEditScreen): new write path — ConcordModeration.editMetadata
  (METADATA edition, entityId = communityId) + Account.editConcordMetadata; prefilled
  from the folded state, honored on fold only for MANAGE_METADATA holders / owner.
- Create screen redesign: shared ConcordMetadataFields with a circular icon hero
  (live preview from the URL) + section headers, mirroring the NIP-29 GroupImagePicker
  layout, replacing the bare stack of text fields.
- Routes ConcordMembers/ConcordEdit + nav registration + top-bar entry points on the
  channel-list screen (Members always, Edit for those who can manage metadata).
- Account.peekConcordInvite for the upcoming invite card.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-11 01:50:49 +00:00
Vitor PamplonaandGitHub e60003063b Merge pull request #3527 from vitorpamplona/claude/nip13-pow-publishing-queue-1takp1
Add NIP-13 Proof-of-Work mining queue with persistence and UI
2026-07-10 21:48:03 -04:00
Claude 80ece7c40f refactor(nip59): replace wrap PoW params with a template-conversion hook
GiftWrapEvent.create no longer takes powDifficulty/powIsActive — it takes a
single templateConversion hook ((template, ephemeralPubKey) -> template,
default identity) that runs on the finished wrap template right before the
ephemeral key signs it. The hook receives the ephemeral pubkey because the
NIP-01 id a nonce commits to includes it and the key never leaves create().

NIP17Factory forwards the same hook through wrapSeal/createWraps and the
create*NIP17 entry points, so quartz's NIP-59/NIP-17 code no longer imports
the NIP-13 miner at all; Account builds the mining closure at the call site.
Any future pre-sign wrap adjustment flows through the same seam.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
2026-07-11 01:17:18 +00:00
Claude 6dd1e4d31d fix(pow): resolve audit findings — durability, coverage, perf and l10n
Durability & correctness:
- Keep the job entry + disk checkpoint alive until the publish continuation
  completes (was: dropped at mining-complete); failed publishes keep their
  checkpoint for restart retry and surface through a failures SharedFlow
- Move draft deletion into the publish continuations in every composer so a
  cancelled or process-killed mining job can't destroy the only copy of a post
- Persist gift-wrap mining: split NIP17Factory into createSeals (signer
  interaction, runs inline) + wrapSeal (pure CPU, runs on the queue), new
  REPLAY_WRAPS records restore pending DM wraps after process death
- Clamp synced NIP-78 difficulty (PoWPolicy.MAX_DIFFICULTY), require a sane
  target in PoWMiner, bound PoWRankEvaluator against short ids
- Reaction double-tap while mining now toggles (dedupeKey + cancelByKey)
  instead of publishing duplicates
- Restore checkpoints for every loaded account, not just the active one
- logOff purges the account's checkpoints and cancels its queued jobs
- Private notes: composer chip now gates on the gift-wrap kind and the
  per-post override reaches sendPrivateNote

Coverage:
- Public/live chat (kinds 42 + 1311) and voice replies (1244 + kind-1 audio
  replies) now route through the mining gate

Perf:
- FGS start() dedupes with a running flag; PendingIntents built once
- Banner 1 Hz clock only ticks while a job shows elapsed time
- PoWEstimator benchmark is single-flight behind a Mutex

UX / l10n:
- Post-mining failures toast with retry information
- Count strings converted to <plurals>; elapsed time via DateUtils; settings
  estimate uses localized units; shared powKindLabelRes replaces three
  duplicated kind→label maps; dead pow_chip_* strings removed
- CLI: pow mine lowercases the pubkey before mining (uppercase hex mined an
  id that never matches the signed event) and validates via quartz Hex

New queue tests: checkpoint lifetime, failure reporting, cancelByKey toggle,
per-owner cancellation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
2026-07-11 00:31:05 +00:00
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 962146bd60 perf(graperank): round-wide communal sweep replaces per-batch backbone re-fan
Retry rounds were the dominant tail cost of deep crawls (rounds 8-10 of a
hop-8 run burned ~80 min for +6.6k lists): every 256-user batch with
attempt>0 or no-outbox users fanned the SAME ~40 backbone+fallback relays,
queueing thousands of small REQs against their 16-permit gates — pure
hot-relay head-of-line blocking.

Restructured:
- routeByOutbox now routes ONLY own-signal relays (write relays; hints when
  no outbox) and flags no-outbox/retry users into a communal set.
- New Phase C communalSweep asks the shared relays ONCE per round for all
  flagged users in author-chunked units — same (relay, user) coverage,
  ~8x fewer REQs — and records answered-empty pairs so each round shrinks.
- Attempt accounting moves to Phase C, after the round's full coverage
  (own relays + shared set) has run.
- shardedSweep now also records answered-empty (user, relay) pairs, so the
  communal sweep never re-asks a pair the shard pass already proved empty.
- Fixpoint recovery dedup: recoverStragglersFromAggregators skips users a
  prior pass already asked the aggregators for (each repeat pass cost ~4 min
  of store scans + REQs at 470k stragglers for zero new answers).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zEYRGKF943RgLaHTViJaB
2026-07-10 22:50:08 +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 97fc801294 fix: NIP-13 compliance — refresh created_at at mining start, clean nonce tag
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
2026-07-10 22:22:04 +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 8988c98308 feat: add NIP-13 proof-of-work publishing with a fire-and-forget mining queue
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
2026-07-10 20:24:45 +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 0dcfe9cfb5 fix(graperank): crawl what the terminal recovery discovers (fixpoint loop)
An A/B rerun of the full-depth crawl exposed a completeness cliff: deep-hop
coverage was hostage to WHEN a contact list arrived. Lists fetched in-round
expand the frontier and get their follows crawled next round; the same lists
recovered by the terminal aggregator pass were counted but never crawled -
the rounds were already over. One weak backbone hour at round 5 shifted ~30k
lists from in-round to the terminal pass and silently amputated ~220k
hop-6/7 users (621k discovered vs 394k on otherwise-identical runs, while
total lists differed only 7.5%).

Extract the round loop into runRounds() and iterate rounds + aggregator
recovery to a fixpoint: while a recovery pass reveals new in-budget pending
users, resume rounds and recover again. Converges because each straggler
folds at most once and the hop budget bounds depth; maxRounds still
backstops. The warm pool stays up through recovery so resumed rounds start
on warm sockets, and a report-deletions top-up runs only when extra rounds
actually happened.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zEYRGKF943RgLaHTViJaB
2026-07-10 18:20:40 +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 743658433f Merge remote-tracking branch 'origin/main' into claude/amy-graperank-crawl-perf-jhmx0x
# Conflicts:
#	cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt
2026-07-10 17:12:10 +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 1614a01ae4 refactor(dns): one resolver everywhere — promote SurgeDns to quartz, fix its blind spots, drop CachingDns
CachingDns duplicated 20% of what the app's SurgeDns already did better
(stale-while-revalidate, single-flight, jittered 24-48h positive TTL,
persistence, poison filtering). Consolidate on SurgeDns and fix what the
verification pass found along the way:

- move SurgeDns + SurgeDnsStore (+ their 41 tests) to quartz jvmAndroid so
  the CLI can share them; delete CachingDns
- negative TTL 10s -> 10min (class default): a dead domain burns 10-30s of
  getaddrinfo per re-dial and both the relay pool and a crawl re-dial dead
  hosts continuously; the relay pool's own backoff already reaches 5min
- stop the call-failure listeners from erasing negative entries they were
  just written from: callFailed caused by UnknownHostException must not
  invalidate (it deleted every negative entry milliseconds after creation,
  silently defeating the negative TTL entirely). The invalidation remains
  for its real purpose - stale positives with rotated IPs
- new SurgeDns.staleAll(): soft-expire everything WITHOUT discarding -
  positives serve stale and revalidate in the background on next use,
  negatives re-try on first touch. Wired to network-identity changes in
  AppModules (same trigger as Tor's onNetworkChange), replacing nothing:
  the full-clear invalidate() was never actually called on network change
- amy: SurgeDns wired into the CLI OkHttp client, snapshot persisted at
  ~/.amy/shared/dns-cache.bin across runs (best-effort load/save)

Verified: all SurgeDns/SurgeDnsStore tests pass in the new location incl.
two new staleAll tests; :amethyst compiles (main + unit tests); cli suite
green. Caveat recorded in the plan doc: behind an HTTP CONNECT proxy OkHttp
never consults the client resolver (hostname goes to the proxy), so this
layer pays off on direct-connect deployments only - which also means the
branch's A/B numbers never depended on it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zEYRGKF943RgLaHTViJaB
2026-07-10 15:51:32 +00:00
Claude 85ad70521b feat(graperank): rebuild --diagnose around where the wall time actually hides
Every hard diagnosis this branch needed lived in blind spots of the old
output — waits that happen before its timers start and stages it never
delimited. Close them:

- permit/rate-gate wait ledger per relay (queueing for a limiter slot
  happens BEFORE the drain timer starts; total + worst offender always,
  top-10 queue table under --diagnose)
- [batch-walls] per round: p50/p90/max of Phase-B batch walls vs the fast
  window, naming the last-resolving unit of the slowest batches - the
  direct measure of pre-drain queueing
- store path split: insert wall now labeled as mutex-wait-inclusive with
  an uncontended-writes estimate (solo batches), plus a read counter
  (351 queries/724ms at hop-2) that makes point-query pathologies visible
- ticker gains visited/s + batches/s so retry rounds over list-less users
  read as "working, zero yield" instead of "stalled" (the done-rate hid
  this for hours)
- per-round limiter demotion counts; stage durations for aggregator
  recovery and report deletions
- [slow-relay] lines deduped to 3 samples per authority with 3-author
  samples (a hop-8 log was ~1MB of repeats; telemetry keeps full counts)
  and the redundant per-drain parked line removed

Smoke-tested live on a hop-2 --diagnose crawl; all suites green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zEYRGKF943RgLaHTViJaB
2026-07-10 14:43:35 +00:00
Claude 3a4b4d5c27 perf(graperank): default drainConcurrency 24 -> 48; record A/B results
Cold hop-3 A/B (fresh store per leg, same observer): 48 workers beat 24
twice at identical contact-list counts (579s->559s and 474s->396s). The
old result that made 64 look 2x slower predated the multithreaded
dispatcher fix - more coroutines on one starved event-loop thread. Plan
doc gains the full A/B table (kept: background-park degating, sweep
dedup, tail overlap, dc48; rejected: 5s fast window).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zEYRGKF943RgLaHTViJaB
2026-07-10 04:41:57 +00:00
Claude e2afe23e52 perf(graperank): overlap the finishing tail instead of serializing it
Timestamped hop-3 logs split the 373s tail three ways: 222s blocking
convergence on parkedInFlight (whose late lists provably cannot fold once
pending is empty - parked filters only ever asked for in-budget users and
they are all done; the events still stream into the store), 14s of
aggregator recovery, and 137s of report-deletion fetching serialized
behind it. Convergence now breaks as soon as the frontier is empty; the
aggregator pass and report deletions run concurrently; and the aggregator
folds the lateHarvest trickle until one full park window of silence
instead of inheriting every background park's lifetime.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zEYRGKF943RgLaHTViJaB
2026-07-10 04:24:26 +00:00
Claude f334402034 perf(graperank): don't re-shard-sweep the same stragglers every round
Straggler rounds re-offered the identical missing set to the identical
top-relay backbone each round, re-paying the rotation barriers (~20s of
Phase A per round) for ~zero new lists. Track swept users and only sweep
newcomers - but only once the backbone is at full shard width, so users
swept against round 2's proto-backbone still get a real sweep when the
top-10 exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zEYRGKF943RgLaHTViJaB
2026-07-10 03:48:20 +00:00
Claude 91835993f6 perf(graperank): stop background sweeps' parks from gating convergence
A hop-3 crawl spent ~500s of its 594s total in the finishing tail waiting
for parkedInFlight to hit zero - and the spike (1791 parked units) was the
fire-and-forget Tier-2 relay-list sweeps, whose whole point was not to
block the crawl. They only gated it because a parked unit persisted its
events at park END, so cancelling early would have lost them.

Fix in two parts: the park loop now persists incrementally (drains + stores
the queued chunk on every activity ping, plus a NonCancellable final drain),
so cancelling a park loses nothing already delivered; and drainGated grew a
background flag - Tier-2 sweep parks (and their capped-page paginations)
no longer count toward parkedInFlight and are simply cancelled at crawl
end. Round-critical parks (outbox drains, sharded sweep, aggregator
recovery) still gate convergence exactly as before. paginateIfCapped takes
(pageSize, oldest) since the incremental path no longer retains the page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zEYRGKF943RgLaHTViJaB
2026-07-10 03:31:11 +00:00