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
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
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
LocalCache.notes is a soft cache, and a Concord rumor is decrypted exactly
once per session (the community session dedups re-delivered wraps), so a
kind-3302 edit left orphaned there could be GC'd on navigation and never
re-downloaded — the message would silently revert to its pre-edit text.
Resetting the channel EOSE doesn't help: the re-delivered wrap is swallowed
by the session's isNew dedup, so its rumor never re-emits.
Fix it the way reactions/replies already survive: attach the edit to the
message it edits. consume(ConcordChatEditEvent) now calls target.addEdit(note),
so the edit is held for exactly as long as its channel-retained message (and
released with it via clearChildLinks). observeConcordEdit reads note.edits
directly — author-matching, latest by CORD-02 §4 send time — instead of
scanning/observing the soft cache.
- Note: new hard-held `edits` collection + addEdit/removeEdit, wired into
clearChildLinks like the other child-event links.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6
LocalCache.computeReplyTo had two `is StreamMessageV2Event ->` branches in the
same `when`. The `when` matches the first, so the second (the older
buzzThreadRoot+buzzThreadReply variant) was dead code — the condition Sonar
flags as duplicating the earlier one. The surviving first branch is the newer,
deliberate Concord-style threading behavior that links a 40002 thread reply into
its parent's replies via buzzThreadReply. Also drops the now-unused
buzzThreadRoot import.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoYFvsXvPigRWebuoGiRoG
- rememberNoteMenuActions is now the single source for the note menu; both the
⋮ overflow (ShareMenu) and the feed right-click ContextMenuArea render the same
items (copy ×5 / broadcast / mute / report).
- LocalSnackbarHost exposes the app SnackbarHostState so moderation actions show a
confirmation: 'Muted user', 'Report sent', 'Reported & muted', 'Broadcast to
relays', and failure toasts. Wired for the note menu + profile ⋮ actions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Report/mute actions published silently with no feedback. Add a DesktopModeration
logger: every report/mute logs kind+id+relay-count on publish, WARNs when there
are 0 connected relays (so a dropped publish is visible instead of silent), and
signing/publish failures are caught + logged (were swallowed by the launching scope).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Manual testing showed the note moderation actions were hard to find:
- Provide LocalDesktopIAccount on MainContent's own (non-null) provider so every
deck/feed/profile/settings surface reliably resolves the account.
- Add a right-click ContextMenuArea on feed notes: Mute user / Report… / Copy text
(moderation items for other authors on a writeable account).
- Swap the note action-row Share icon for a MoreVert (⋮) overflow — the menu it
opens already carries copy/broadcast + mute/report, matching the profile ⋮.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Going / Maybe / Can't go buttons in the calendar feed card wrapped
to two lines because the default button content padding (24dp horizontal)
left no room for "Can't go" in an equal-weight third of the row.
Tighten the content padding, shrink the row spacing, and constrain each
label to a single line so the three buttons fit horizontally.
The parent message shown above a reply in the MessagingStyle notification was
unconditionally labeled "Me" with the account's avatar. That's wrong whenever
the account is notified as the *root* author of a thread but the direct parent
belongs to someone else — e.g. Vitor starts a thread, fiatjaf replies, a third
person replies to fiatjaf: fiatjaf's note was rendered as Vitor. This surfaced
through the NIP-22 comment and public-chat reply paths, which notify on
root-authorship, not just direct-parent authorship.
ReplyNotification now receives the parent Note (not a bare content string) and
resolves its actual author: attributed to the MessagingStyle `me` Person only
when the parent truly is the account's, otherwise shown as the real author with
their name + avatar, observed for enrichment like the replier. postConversation
reuses the `me` Person for a self-authored parent so it still renders as you.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0122uQ8BLHLeHDni81RBP26r
When a mention/article body contains an inline `http(s)` image link, show the
image as the notification's big picture (BigPictureStyle) instead of leaving the
raw URL in the text. NotificationContent.renderNoteText() now pulls the first
image URL out of the content — scanning all lines, since images usually sit on
their own line — using the cheap RichTextParser.isImageUrl extension check, and
strips that link from the excerpt. Videos are left in the text (Coil can't load
them as a still). The Mention and Article renderers pass the extracted URL as
bigPictureUrl; Reply stays MessagingStyle (a big picture doesn't fit a chat
bubble) and keeps its text as-is.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0122uQ8BLHLeHDni81RBP26r
Notification bodies previously showed raw `nostr:npub1…` / `nostr:nprofile1…`
tokens for anyone cited in the text — the name never filled in even after the
cited user's kind:0 loaded, because the excerpt was a static substring taken
once and never re-resolved against the metadata cache.
Add NotificationContent.resolveMentions(), which rewrites each cited npub/
nprofile token to `@<best display name>` and returns the cited Users so the
renderer can add them to its enrichment window. Event references (nevent/note/
naddr) are left verbatim — they have no name to show. Mention, Reply, Media and
Article renderers now recompute the body inside the observable build closure and
observe the cited users, so the text flips from `@npub1abc…` to `@RealName` in
place, matching the author-name enrichment the title already had.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0122uQ8BLHLeHDni81RBP26r
Watch kind-3302 edits reactively through LocalCache.observeEvents, narrowed on
the edit's `e` tag, instead of a whole-cache scan wired to the target note's
edits flow. The index-backed observer seeds from any edit already cached and
wakes on each new one, matching how the app observes reactions, Nest presence,
and git PR updates.
- observeConcordEdit now collects observeEvents<ConcordChatEditEvent>({kinds,
"#e"}); it filters to the original author and takes the latest by send time.
- consume(ConcordChatEditEvent) drops the manual edits-flow invalidation and
just lands the event + wakes observers (refreshNewNoteObservers).
- Removed the now-unused findLatestConcordEditForNote scan and concordEditCache.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6
Verified against the Concord v2 reference client (Soapbox Armada,
src/concord-v2/lib/kinds.ts): a chat message edit is a dedicated KIND_EDIT =
3302 rumor, NOT a kind-1010 modification. It names the target with a single
`e` tag (no `k` — Armada adds `k` only to deletes), carries the replacement
text, and rides the channel/epoch binding. The fold applies only edits
authored by the original message's author (latest by CORD-02 §4 send time
`created_at*1000 + ms`), non-destructively.
The prior commit used kind-1010 TextNoteModificationEvent, which would not
interop with Armada. Corrected:
- New ConcordChatEditEvent (kind 3302) in quartz, registered in EventFactory;
ChannelChat.edit now builds it. orderingMs() honors the `ms` remainder tag.
- LocalCache.consume(ConcordChatEditEvent) wires the edit to its target note
and invalidates the edits flow; findLatestConcordEditForNote returns the
author-matching kind-3302 edits ordered by send time (latest wins).
- observeConcordEdit reads that finder instead of the kind-1010 machinery.
Send/compose/action-sheet plumbing is unchanged (it routes through
ChannelChat.edit). Note: Amethyst does not yet emit the `ms` remainder tag on
Concord rumors (a pre-existing, message-wide gap), so its own edits order at
one-second granularity; received Armada edits are ordered at full precision.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6
Brings feed-post-style edits to Concord chat. A Concord message rumor is a
standard kind-9 event, so an edit reuses Amethyst's native kind-1010
TextNoteModificationEvent: a channel/epoch-bound rumor that e-tags the target
and carries the replacement text, wrapped and published on the same channel
plane as any other Chat Plane rumor. Receivers overlay the newest edit through
the existing shared machinery (LocalCache.findLatestModificationForNote), which
only applies edits authored by the original message's author, so a member can't
rewrite someone else's message. Clients that don't understand kind-1010 keep
showing the original text, so it degrades gracefully.
- ChannelChat.edit + ConcordActions.buildChannelEdit build/wrap the edit rumor.
- Account.editConcordChannelMessage gates to my own kind-9 messages and
publishes the wrap (local echo + relays), mirroring reactToConcordMessage so
the edit never leaks the private rumor id onto public relays.
- The chat bubble overlays the newest edit (RenderConcordEditedNote) with an
"(edited)" marker, matching the Buzz kind-40003 edit presentation.
- The long-press action sheet offers Edit on my own Concord messages; the
composer enters edit mode with an editing banner and publishes the edit on
send. The former onWantsToEditBuzz callback is generalized to
onWantsToEditChatMessage, shared by the Buzz and Concord surfaces.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6