The File Sync / Import flow (and the mirror-on-upload fan-out) copy blobs
across the user's Blossom servers with BUD-04 `PUT /mirror`, but not every
server implements that endpoint. Blossom has no capability-discovery
mechanism, so a target without /mirror just answered 404/405/501 and the
whole copy was silently counted as failed.
Detect the "endpoint absent" statuses (404/405/501) as a typed
BlossomMirrorUnsupportedException — distinct from a mirror the server
understood but rejected (400/403/413/…) — and add BlossomClient.mirrorOrUpload,
which falls back to downloading the blob and re-uploading it (PUT /upload)
when mirror is unsupported. The downloaded bytes are verified against the
expected sha256 before re-upload, since a Blossom server is untrusted and
could substitute content, and the same t=upload auth is reused.
Wire every mirror path through mirrorOrUpload: the app-level
BlossomMirrorQueue (sync-all + import sweep), the blob manager's per-blob
mirror (including the paid-mirror retry), and UploadOrchestrator's
mirror-on-upload. Task now carries the descriptor content-type so the
fallback upload preserves the MIME.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168TWLTgrMxUR6yjjCCiBLS
A Concord community pinned to the bottom bar folded only a fraction of its
channels — Soapbox showed 1 of 12. The live subscription collapsed a
community's Control + Guestbook + rekey + every channel plane into ONE
kind-1059 filter per relay, and the channel list is folded from the Control
Plane. On an AUTH-gated relay that caps a REQ per filter (measured ~100
events/filter on relay.dreamith.to), the chatty Guestbook plane crowded the
channel-defining control editions out of the cap, so only a fraction of the
channels folded. On the strict relay.ditto.pub the collapsed multi-author
filter is refused wholesale until every author is authenticated.
- Split the Control Plane into its OWN filter, apart from the Guestbook /
rekey / channel planes (ConcordSubscriptionPlanner.controlIsolatedFilters),
so it gets an isolated per-filter budget. Both filters still ride the same
per-relay REQ (no extra socket).
- Add a COMPLETE-mode Control-Plane sweep (Account.syncConcordControlPlanes):
re-fetch the whole plane with no `since`, paging past the per-filter cap via
fetchAllPagesFromPool, so a forward cursor can never hide an edition and the
cap can never truncate the fold. Mounted account-wide, fired on load +
membership/held-epoch change + relay reconnect (not a wall-clock poll — the
persistent live subscription keeps a connected relay complete).
Mirrors Armada's plane-sweep design (one filter per plane scope, COMPLETE-mode
control). Verified on-device: Soapbox now folds all 12 channels.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Widen the fix beyond the plain stream chat message: every Buzz kind the chat
feed renders as a row — stream messages (40002), system lines (40099), diffs
(40008), and the agent-job (43001-43006) and huddle (48100-48103) lifecycle
events — is `h`-scoped and attaches to the same RelayGroupChannel as a kind-9
via consumeBuzzTimelineEvent. So all of them must count as a room's newest
message and toward its unread dot; leaving them out left the Messages-list
preview stale whenever the newest thing in a channel was one of these.
- Add `Event.isBuzzChatTimelineContent()` (quartz buzz) enumerating exactly the
kinds consumeBuzzTimelineEvent attaches / the chat renders — excluding edits
(folded into their target), canvas, and forum kinds. `isGroupChatContent()`
now ORs it in, so the initial scan, the live additive update, and the unread
dot all agree.
- These kinds carry JSON/diff in `content`, so previewing raw `content` would
dump `{"ephemeral_channel_id":…}`. Extract the in-chat labels into pure
helpers (`buzzSystemMessageText`, `buzzActivityLabel`,
`buzzTimelinePreviewSummary`) so the Messages-list preview shows the same
human-readable summary the chat row shows ("🔊 huddle started", "topic
changed", "⚙ job progress: …", "📄 <file>") instead of raw payload.
- Extend the regression test to cover stream/system/huddle counting and edit/
reaction not counting.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012dtx8Shek4sSAXmHjnNMJF
The bottom-bar settings picker only exposed one level of each grouped chat
system: NIP-29 let you pin an individual group but not the host relay, while
Concord let you pin a community but not an individual channel. Since a NIP-29
relay is the analog of a Concord community (the container) and a Concord
channel is the analog of a NIP-29 group (the item), both systems now offer
both levels.
- Add BottomBarEntry.RelayServer(relayUrl) -> Route.RelayGroupServer (the
relay's home page of all joined groups) and BottomBarEntry.ConcordChannel(
communityId, channelId, relays) -> Route.Concord (a specific channel), with
stable @SerialName discriminators and stableKeys.
- Resolve their live avatar/label/route: the relay via its cached NIP-11 doc,
the channel via the community session's folded Control Plane (community icon
+ channel name).
- Regroup the picker by container: each relay/community is an addable "server"
row with its groups/channels nested beneath it, so you can add the whole
server or a single room in both systems.
- Bootstrap a pinned channel's community list (importConcordCommunities and the
pinned-community preloader now also read ConcordChannel entries).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012v531djZmQBxVoyCm45BNY
A Buzz stream-channel chat message is a kind-40002 StreamMessageV2Event, not a
NIP-C7 kind-9 ChatEvent. It is `h`-scoped and attaches to the same
RelayGroupChannel as a kind-9, but `isGroupChatContent()` only recognized
ChatEvent/PollEvent/ThreadEvent/CommentEvent, so the Messages-list "newest
message" logic (initial scan `newestChatNote` + the live additive
`filterRelevantRelayGroupMessages`) and the unread-dot check all skipped it.
Result: a Buzz channel's row never reflected its real chat and never updated
live as new messages arrived.
Include StreamMessageV2Event in `isGroupChatContent()` (the Buzz dialect of
NIP-29), fixing the Messages preview and unread dot in one place. Adds a
regression test asserting kind-40002 counts as group chat content while a
group-scoped reaction does not.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012dtx8Shek4sSAXmHjnNMJF
- Swap the Buzz community channel favorite from a Star to a PushPin icon
(and rename the buzz_star/buzz_unstar strings to buzz_pin/buzz_unpin),
since the action only pins a channel to the top of the list — it never
publishes anything, unlike Add which imports into the kind-10009 list.
- Give the "Added" state in BuzzImportRow trailing padding so its label no
longer jams against the row edge when the Add button flips to Added.
- Prefix a Messages-list DM preview with "You:" when the newest message was
sent by the logged-in user, so a room shows who spoke last.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012dtx8Shek4sSAXmHjnNMJF
The bespoke applicationIOScope watcher that opened its own relay
subscription for NIP-47 wallet notifications is replaced by an EOSE
manager registered in AccountFilterAssembler.group — the same always-on
scheme as the account's zap/notification inbox subscriptions. It is now
owned by the single AccountFilterAssemblerSubscription in LoggedInPage,
kept warm in the background by NotificationRelayService, and torn down on
logout — matching zap-receipt lifecycle exactly (and not gated on OS
notification permission).
- NwcNotificationsEoseManager (PerUserEoseManager): one filter per
connected wallet's own relay (kind 23197/23196, #p = per-wallet client
pubkey), re-invalidating when the wallet set changes, `since`-floored at
watch start, deduped by a seen set. Because these events are ephemeral,
encrypted, and never land in LocalCache, onEvent decrypts them via
NwcSignerState.handleIncomingNotification.
- NwcSignerState.handleIncomingNotification decrypts with the matching
wallet's connection secret, drops zap-carrying payments, and publishes
non-zap payments to a new incomingNonZapPayments SharedFlow — a clean
seam a future in-app Notifications-tab consumer can also drain.
- NwcPaymentNotificationWatcher is now just the Context-bound bridge that
drains that flow into an OS tray notification (no relay work, no client
dependency).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDAAS4ktFbWtRnEVXQsjfs
The app-level "sync all" / import progress banner switched to "Sync
complete" when the sweep finished but then lingered until the user
tapped X. Auto-dismiss it a few seconds after completion, keeping the X
for dismissing early. Keyed on the running flag so a new sweep cancels
the pending dismiss and tapping X re-keys it to a no-op.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5G515Grhc4t7ACoza9eyN
Follow-up fixes from an audit of the import feature:
- Cancel an in-flight scan when the source selection changes
(toggle/add/remove/enable-all). Without this a scan started against
the old selection could land afterwards and offer blobs sourced from a
server the user just de-selected — which importSelected() would then
mirror from.
- Sign the BUD-02 list token once per scan and reuse it across every
source and target. The token carries no `server` scope tag, so it's
valid everywhere; per-server signing was a round-trip storm with
remote NIP-46 signers.
- BlossomMirrorQueue.start() now returns whether it actually started a
sweep. importSelected() keys the Started/Busy result off that instead
of a separate isRunning check, closing a TOCTOU where the import would
report "started" but the queue silently dropped the work.
- The import screen's empty-state now collects the kind-10063 server
list reactively, so the "add servers first" ↔ picker switch recomposes
when the list arrives from a relay after the screen opens.
- init() re-points at the current account each call (matching the
sibling BlobManager VM) while still seeding the source list only once.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5G515Grhc4t7ACoza9eyN
verifyOts wrote otsVerification = Verifying before the suspending blockchain
verifyState() call and only overwrote it with the real verdict afterwards. That
verification runs inside LoadOts's cancellable LaunchedEffect, so a row scrolling
off-screen cancels the coroutine at the network suspension point — the verdict
write never happens and the note is left stuck at Verifying. cacheVerifyOts
treats Verifying as terminal, so earliestOtsVerifiedTime then returns null and the
confirmed-timestamp pill silently vanishes for the note's lifetime. The old
VerificationStateCache had the same write but its LRU eventually evicted the stuck
entry; anchoring the verdict on the long-lived note removed that recovery.
Store only terminal verdicts (Verified / Error / NetworkError). A cancelled or
in-flight verification leaves the field null and simply retries on the next read;
the cost is at worst two concurrent first-time verifications, already benign.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014D5fenZbAhCbDiwv7Rtpvj
Two issues found in an audit of the NWC changes:
1. (correctness, high) NIP-44 negotiation never triggered against real
wallets. The info event carries schemes in a single space-separated
tag value (["encryption", "nip44_v2 nip04"]), but encryptionSchemes()
returned tag.drop(1) = ["nip44_v2 nip04"], so the nip44_v2 membership
check never matched and every request fell back to NIP-04. Split each
tag value on whitespace in encryptionSchemes()/notificationTypes() so
both the spec's space-separated form and a multi-element tag normalize
to individual tokens. Adds NwcInfoEvent tests for the wire format.
2. (performance) NwcPaymentNotificationWatcher subscribed via
subscribeAsFlow, which accumulates every event into an ever-growing
list and re-emits the whole list per event — wrong for a lifetime
subscription (unbounded retention + O(n) rescan per event). Replace
with a raw client.subscribe listener (callbackFlow) that emits each
event once; reconnect re-delivery is still de-duped by the seen set.
Also documents why the watcher keys the account flow on pubkey.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDAAS4ktFbWtRnEVXQsjfs
Replace the single-purpose per-wallet "supports nip44" boolean with a
shared NwcInfoCache that stores each wallet's full kind 13194 info event
(capabilities + encryption schemes + notification support), keyed by
wallet pubkey and owned by Account.
- Entries expire after 2 days so a wallet that changes its advertised
capabilities is eventually re-checked. Reads never block: the payment
path reads the cached value and nudges a background refresh when the
entry is missing or stale (self-healing without holding up the tx);
failed fetches are not cached, so a transient error retries next use.
- NwcSignerState derives the NIP-44 preference from the cache.
- NwcPaymentNotificationWatcher now consults supportsNotifications() and
skips opening a relay subscription for wallets that advertise none
(fail-open when the info event is unknown).
Adds NwcInfoCacheTest covering caching, TTL expiry, no-cache-on-failure,
and definitive-missing-info caching (injectable clock).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDAAS4ktFbWtRnEVXQsjfs
NIP-03 OpenTimestamps attestations (kind 1040) were stored loosely in the main
note cache and found via a full-cache scan, with their blockchain verdicts held
in a separate, id-keyed VerificationStateCache LRU. Nothing tied either to the
lifecycle of the note being timestamped, so a deleted/pruned note leaked its
attestations, and consume(OtsEvent) invalidated the attestation's own
(observer-less) flow instead of the target's — so a live-arriving proof never
pinged the target's UI.
Mirror the recent edits→Note migration:
- Note gains a `timestamps` child collection (like `edits`/`reactions`), wired
into clearChildLinks/removeNote, so an attestation survives exactly as long as
its target and is collected when the target is pruned or deleted.
- consume(OtsEvent) anchors the proof on its target via the `e` tag and
invalidates the target's `ots` flow; unlinkAndRemove detaches it symmetrically.
- Each attestation memoizes its own verdict in `Note.otsVerification`, so the
result shares the note's lifecycle. This replaces VerificationStateCache
(deleted) and the full-cache scan: the OTS pill now folds `note.timestamps`
via the new Note.earliestOtsVerifiedTime / cacheVerifyOts helpers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014D5fenZbAhCbDiwv7Rtpvj
Every chat composer in Amethyst already had a picture/media attach button
(SelectFromGallery) except the minichat "thread" screen — the kind-1111
reply composer opened from the "N replies" chip — which was text-only. This
brings it to parity with every other chat.
- quartz: ChannelChat.imageReply() — a kind-1111 thread reply carrying
encrypted image imeta(s), combining reply()'s NIP-22 pointers with
imageMessage()'s ciphertext-URL/imeta handling (+ round-trip test).
- commons: ConcordActions.buildChannelImageReply().
- Account.sendMinichatReply() now accepts imetas and routes per backend:
Concord sends an encrypted image reply; NIP-28/NIP-29 public chats append
the URL to the content and carry a plaintext imeta on the comment; Buzz
appends the URL to the stream message content.
- Extract toConcordImeta()/toPlainImetas() into a shared UploadImetas.kt so
the minichat and Concord composers build imeta the same way.
- MinichatScreen: add the SelectFromGallery leading icon + ChatFileUpload
dialog, encrypting only when the backend is end-to-end (Concord).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D8FD5xm8nyEKzd8dk9VfT1
NIP-44 encryption preference: NwcSignerState now fetches each wallet's
kind 13194 info event (via the relay client, injected by Account),
caches whether it advertises `nip44_v2`, and sends pay/RPC requests
with NIP-44 when supported — satisfying NIP-47's "client should always
prefer nip44 if supported by the wallet service". Falls back to NIP-04
(the legacy default) when unknown or unsupported; response decryption
already auto-detects the scheme.
Non-zap payment notifications: NwcPaymentNotificationWatcher keeps a
standing subscription to each connected wallet's NIP-47 notification
stream (kind 23197/23196) on the wallet relay, decrypts payment_received
events, and posts a tray notification on a new Payments Received channel.
Payments carrying a NIP-57 zap request are skipped, since those already
surface through the kind-9735 ZapNotification path — so only plain,
non-zap incoming payments are announced.
Deep-link pairing: the "Connect wallet via app" button now builds its
`nostrnwc://connect` URI through the tested quartz Nip47DeepLink helper
instead of a hardcoded percent-encoded string.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDAAS4ktFbWtRnEVXQsjfs
Add Nip47DeepLink for the NWC-07 same-device pairing convention:
build/parse the `nostrnwc://connect` request (client -> wallet) and
the callback URI that returns the `nostr+walletconnect://` pairing code
(wallet -> client). All params are URI-encoded per the spec.
Also thread `useNip44` through LnZapPaymentRequestEvent.create so
pay_invoice requests can opt into NIP-44, matching createRequest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDAAS4ktFbWtRnEVXQsjfs
Videos in the gallery now show a decoded first frame (Coil
VideoFrameDecoder) with a play badge instead of a generic glyph; the same
preview is reused in the detail header, with a type-glyph fallback for
blobs Coil can't decode (e.g. HLS playlists).
Tapping an image or video tile now opens a full-screen viewer: images are
zoomable/pannable (engawapg zoomable, matching ZoomableImageDialog) and
videos play inline. Its top bar carries back, a new share action (system
share sheet for the blob URL), and a button that reveals the file's
storage matrix and sync/copy/open/share/report/delete actions in a bottom
drawer. Non-visual blobs (PDFs, arbitrary files) still open straight to
that action sheet. The actions list is now a shared BlobActionsContent so
the sheet and the viewer drawer stay identical.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EDLTQZM6yX13EwaYFNWTz7
On the "My Blossom Files" screen, replace the top-bar sync icon with a
3-dot overflow menu offering "Refresh" and "Import files…".
The new Import flow lets the user pull files they've uploaded to other
Blossom servers into their own. They pick source servers to check —
seeded from the recommended bootstrap list (minus servers already in
their own list, with an enable-all toggle) and/or hand-typed addresses.
A scan fans a BUD-02 GET /list/<pubkey> across the enabled sources,
works out which of those blobs are missing from the user's own
kind-10063 servers, and hands the gaps to the existing app-level
BlossomMirrorQueue so each server fetches them via BUD-04 mirror —
reusing the same floating progress banner as the on-screen sync.
- BlossomImportViewModel: source list management, scan, gap detection,
mirror hand-off.
- BlossomImportScreen: server picker, custom-URL field, scan + results.
- New Route.ImportBlossomBlobs wired into AppNavigation.
- Strings + plurals for the import UI.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5G515Grhc4t7ACoza9eyN
Replace the single-column list of large blob cards with an adaptive
thumbnail grid so many files no longer mean an endless scroll. Each tile
shows an image preview (or a type glyph) plus a corner badge summarizing
how many of the user's servers hold it (green check when on all, amber
cloud with a present/total count otherwise).
Tapping a tile opens a bottom sheet with the file's details: hash,
type/size, the per-server storage matrix ("Stored on"), the sync
(mirror-to-missing) button, and the copy/open/report/delete actions that
previously lived behind the card's overflow menu.
The ViewModel is unchanged — only the presentation layer moved from
list-of-cards to grid-plus-detail-sheet.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EDLTQZM6yX13EwaYFNWTz7
Audit of the three edit paths (feed 1010 / Buzz 40003 / Concord 3302) found:
1. Bug (feed regression): a deleted edit kept overlaying its message. Edits
anchor on the target's Note.edits with no `replyTo` back-link, and
removeNote didn't cover `edits`, so unlinkAndRemove never dropped them — the
old cache-scan resolver dropped deleted edits for free, Note.edits did not.
Fix: removeNote now also removeEdit()s, and unlinkAndRemove resolves the
edit's `e`-tag target and unlinks it there (editedTargetIdOf covers all
three kinds). New test: deleting an edit un-overlays and unlinks it.
2. Perf: every chat row ran two edits-flow collectors (observeConcordEdit +
observeBuzzEdit). A message is only ever one kind, so they're merged into a
single observeChatEdit that resolves latestConcordEdit() ?: latestBuzzEdit()
— one collector per row, dispatched by the winning edit's event type.
3. Nits: latestBuzzEdit now tie-breaks by idHex (deterministic on same-second
edits, matching Concord); dropped a redundant takeIf in latestConcordEdit.
The author check stays at read time on purpose: an edit can be consumed before
its target loads (author unknown), so an attach-time gate would wrongly drop
early-arriving legit edits.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6
findLatestModificationForNote (and the Buzz resolver) were pure Note.edits
filters with no LocalCache state — they only lived there for historical
reasons (back when resolving edits meant scanning the whole cache). Now that
every edit is a hard-referenced child of its message, resolution is a cheap
in-memory fold that belongs on the note.
New NoteEditOverlays.kt collects all three as Note extensions, so every edit
kind resolves the same way and none touches LocalCache:
- Note.textNoteModifications() (1010, author-only + NIP-40, version list)
- Note.latestBuzzEdit() (40003, author-only, newest by created_at)
- Note.latestConcordEdit() (3302, author-only, newest by CORD-02 send time)
Callers updated: observeEdits, observeNoteModifications, observeBuzzEdit,
observeConcordEdit, and the Buzz test. observeConcordEdit also drops its
early-return-before-produceState guards (a conditional-hook hazard) since the
resolver returns null for a non-Concord note anyway. LocalCache no longer
carries any edit-filtering logic.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6
It existed to serve observeEdits a cheap synchronous value (an LRU read)
distinct from the expensive IO-only findLatestModificationForNote cache scan.
Since edits fold from Note.edits, findLatestModificationForNote is itself
cheap and thread-safe, and cachedModificationEventsForNote had become a plain
alias for it. observeEdits now calls findLatestModificationForNote directly —
the same function observeNoteModifications already uses — and the LocalCache +
AccountViewModel aliases are removed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6
The first cut appended :geode:test to the build-desktop matrix command.
That was wrong twice over: geode is JVM-only, so it ran 3× across the
ubuntu/macos/windows matrix, and — because org.gradle.parallel=true —
its default suite's CPU-heavy throughput benchmarks (a 1M-event mirror
sync, WireReqFloor, NegentropyServerReconcile) ran concurrently with the
timing-sensitive quartz relay-client tests, flaking
NostrClientReqBypassingRelayLimitsTest.denseSecondBeyondCapIsSteppedPastWithoutStalling.
Move :geode:test into its own test-geode job (needs: lint, ubuntu, JVM
21) so it runs once and its benchmark load can't starve another module's
timing assertions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KCdJwdhGtmLZ12ViS56S3k
geode was runnable only via ./gradlew :geode:run and was absent from CI.
Give it the same release process as the amy CLI (it's the same kind of
application-plugin JVM module), plus the pieces a long-running server
daemon needs that a one-shot CLI does not.
- Main.kt: add terminal --version/-V and --help/-h flags so a packaged
binary has a fast, exit-0 command (Homebrew test block, package smoke
checks, Docker healthcheck).
- build.gradle.kts: jlinkRuntime + geodeImage (portable flat app-image
with a bundled JRE, plus config.example.toml + geode.service under
share/) + jpackageDeb/jpackageRpm, mirroring cli/. No Compose to
exclude — geode depends only on :quartz.
- Dockerfile + .dockerignore: multi-stage image (gradle installDist ->
temurin JRE), the primary channel for relay operators.
- packaging/: systemd unit, macOS hardened-runtime entitlements, and a
reference Homebrew formula.
- scripts/asset-name.sh: geode_asset_name/collect_geode_assets under the
canonical geode-<version>-<family>-<arch>.<ext> scheme.
- create-release.yml: build-geode matrix (tarball + deb/rpm + no-JRE jvm
bundle, with a serve+NIP-11 smoke test of the jlink image) and a
docker-geode job pushing ghcr.io/<owner>/geode:<version> (+ :latest).
- bump-homebrew-geode-formula.yml: auto-sync the reference formula on
stable releases.
- build.yml: run :geode:test in CI (it ran in no workflow before).
- README.md + plans/2026-07-24-geode-release.md: operator docs + design.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KCdJwdhGtmLZ12ViS56S3k
The Buzz edit overlay applied the newest kind-40003 by created_at regardless
of who signed it, so a 40003 signed by anyone — targeting someone else's
message — would rewrite that message in every reader's UI. The send side
already gates Edit to your own messages, but the apply side re-checked
nothing and effectively trusted the relay to reject cross-author edits.
Enforce author-only on apply, matching feed (1010) and Concord (3302) edits:
observeBuzzEdit now resolves through LocalCache.findLatestBuzzEditForNote,
which keeps only edits whose author is the original message's author (newest
by created_at — Buzz's own last-write-wins rule otherwise). There is no Buzz
feature that edits another user's message; moderation is delete/hide.
Added a test: a verified forged edit by a different author lands in the store
but never overrides the message, while the real author's later edit does.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6
All three edit kinds now anchor on the message they edit via the same
Note.edits collection, instead of each maintaining its own store:
- Feed edits (kind 1010): consume(TextNoteModificationEvent) calls
editedNote.addEdit(note); findLatestModificationForNote folds note.edits
(author-only, NIP-40 expiry) instead of scanning the whole cache. Drops
the O(all-notes) scan and the 20-entry modificationCache LRU;
cachedModificationEventsForNote is now synchronous (no Loading state).
- Buzz edits (kind 40003): consume(StreamMessageEditEvent) calls
target.addEdit(note); observeBuzzEdit reads note.edits (newest by
created_at, no author gate — Buzz's own rule). Removes the channel-keyed
BuzzWorkspaceState edit store, its editUpdates/editFor/effectiveContentFor/
addEdit and the pruneEdits reaping (edits now prune with their message).
- Concord edits (kind 3302): already on note.edits.
Each reader keeps its own semantics by filtering note.edits on its event
type; the shared field only unifies storage + lifecycle, so an edit lives
exactly as long as the message it edits. Buzz edit tests rewritten against
note.edits (6/6 green).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6
- don't double-return the player if register() throws
- guarantee pool teardown even if a session retire throws
- simplify pool ownership code, one funnel and paired counters
- fix(playback): remove pool from livePools after its own teardown
- fix(playback): floor decoder decrements and report teardown drift
- fix(playback): own the player across the acquire-to-register window
- fix(playback): give every pooled player exactly one owner
Sweep of Kotlin compiler warnings in every module's main source sets
(quartz, commons, cli, desktopApp, amethyst, nappletHost).
Genuine code fixes:
- Drop unnecessary !!/safe-calls and redundant elvis/casts (OkHttp's
now-non-null `body`, smart-cast callbacks, non-null String receivers).
- Remove provably-redundant conditions (`canvas == null` after a
non-null content check; `account != null` implied by `canModerate`).
- Migrate deprecated kotlinx.collections.immutable persistent ops
(add/remove/put/addAll -> adding/removing/putting/addingAll).
- Migrate LocalClipboardManager -> LocalClipboard (+ scoped setText),
ContextCompat.startActivity -> context.startActivity, TabRow ->
SecondaryTabRow, and @ConsistentCopyVisibility on a private-ctor data class.
- Delete dead ReceiveDialog.onGenerate param (never invoked).
- Fix a platform-Boolean type-mismatch on a ThreadLocal read.
Deprecations with no available successor are narrowly @Suppress-ed with
a reason: androidx.security.crypto (EncryptedSharedPreferences/MasterKey),
androidx.privacysandbox.ui, WebView.databaseEnabled, BluetoothDevice
.connectGatt, media3 setEnableAudioTrackPlaybackParams, FirebaseMessaging
.token, and InputMethodManager.SHOW_IMPLICIT.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Xb9YbBqhdZsHxMzitmyvn
Geode hard-wired the SQLite EventStore. Add a `[database].backend`
selector (and `--store` CLI flag) so an operator can choose the store
implementation:
- "sqlite" (default): the SQLite EventStore, unchanged.
- "fs": quartz's filesystem FsEventStore, rooted at [database].file.
- any other value: a fully-qualified class name of a custom
IEventStore on the classpath, instantiated reflectively via one of
`(NormalizedRelayUrl?, IndexingStrategy)`, `(NormalizedRelayUrl?)`,
or `()` — the "plug in anything" escape hatch.
Store construction moves into a new StoreFactory (mirrors cli's
StoreFactory) shared by the serve path and the import/export verbs, so
both open the same store from the same config. The SQLite-only
`PRAGMA optimize` maintenance loop now runs only when the resolved
store is the SQLite one.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GG3TBvLUv5uB5js1naG5sc