On cold boot an AUTH-required kind:10050 DM-inbox relay usually sends its
AUTH challenge before the account's own kind:10050 list has been fetched.
AuthApprovalPolicy.classify reads the trusted (self-approved) relay set
exactly once, so it classifies the user's OWN inbox relay as tier-2 and
surfaces a manual `[Once][Always][Never]` banner. Nothing re-evaluates
that pending decision when the kind:10050 list finally loads, so the user
gets a spurious AUTH prompt for a relay that should have auto-signed.
Extract the pending-approval set into a platform-agnostic
commons/AuthApprovalRequests (add/resolve/cancelAll) and add
autoApproveNowTrusted(): when the DM-inbox set updates, retroactively
settle every pending prompt whose relay is now tier-1 with ONCE — sign
this session, do not persist (trusted by identity, not an explicit grant).
DesktopAuthCoordinator now delegates its pending set to AuthApprovalRequests
and exposes onSelfApprovedRelaysChanged(); Main.kt drives it from
DesktopAccountRelays.dmRelayList (the account's kind:10050 StateFlow).
AuthApprovalRequestsTest reproduces the cold-boot race (red before the fix)
and covers the resolve / cancelAll paths.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
GalleryListEvent (kind 10011, @Deprecated "Replaced by NIP-68") was
unreachable: EventFactory routes kind 10011 to ExternalIdentitiesEvent,
and no other code referenced the class. It also shadowed NIP-51's
"Favorite follow sets" kind. Removing the dead class; the live profile
gallery entry (ProfileGalleryEntryEvent) and its builders are untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017giudm3gXumsxZmd3uMQc8
NIP-51's kind:10000 mute list defines four entry types — `p` (pubkeys),
`word`, `e` (threads) and `t` (hashtags). Quartz parsed only the first
three, so `t` hashtag mutes written by other clients were silently
dropped: uncounted, invisible, and never applied to filtering.
Quartz:
- Add HashtagTag (`"t"`) implementing the MuteTag sealed interface, and
register it in MuteTag.parse/isTagged so it round-trips like the other
entry types.
- Add mutedHashtags()/mutedHashtagIds() TagArray helpers.
Filtering (commons):
- Add hiddenHashtags to LiveHiddenUsers plus isHashtagHidden(), and hide
notes carrying a muted hashtag in Note.isHiddenFor() (exact, case-
insensitive `t`-tag match — distinct from the existing substring word
scan).
Amethyst:
- Aggregate HashtagTag entries from the mute/block lists in
HiddenUsersState.
- MuteListState.hideHashtag/showHashtag + Account and AccountViewModel
wrappers, and observeUserIsMutingHashtag.
- Surface a Mute/Unmute hashtag action in the hashtag screen's options
overflow menu.
Tests: HashtagTagTest (parse/round-trip/MuteTag dispatch) and
NoteIsHiddenForTest cases for muted-hashtag hiding.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017giudm3gXumsxZmd3uMQc8
The previous regression test drove PreferencesAuthApprovalStore against
the real java.util.prefs.Preferences.userRoot() backing store, which
threw IllegalArgumentException on the macOS CI runner (its preferences
backend rejects operations the Linux one accepts).
Extract the key derivation into a pure `authApprovalPreferenceKey`
function and assert the invariant that actually matters — every derived
key stays within Preferences.MAX_KEY_LENGTH, which is exactly the
condition put() enforces. No OS I/O, so it runs deterministically on
every platform.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015aJPf1AsPTdzdMYR6pZ4e2
The desktop AUTH banner ("<relay> requires authentication to deliver
this message") kept re-appearing on every challenge even after the user
clicked Always or Never.
Root cause: PreferencesAuthApprovalStore used the raw relay URL as the
java.util.prefs.Preferences key. Preferences caps keys at
MAX_KEY_LENGTH (80 chars) and throws IllegalArgumentException from put()
for anything longer. Outbox-proxy relay URLs that embed an npub and a
query string routinely exceed that (e.g.
wss://filter.nostr.wine/npub1...?broadcast=true is 102 chars), so
setScope threw. RelayAuthenticator swallows the exception, so the
ALWAYS/BLOCKED grant was silently never persisted and the relay
re-prompted on the next AUTH challenge.
Fold any relay URL over MAX_KEY_LENGTH into a bounded sha256:-prefixed
64-char hex key. Short URLs are still stored verbatim so existing grants
keep working.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015aJPf1AsPTdzdMYR6pZ4e2
Follow-up making the invite redeemer match the CORD-05 §2 spec for the
addressable invite coordinate (33301, link_signer, d=""):
- vsk=6 → live bundle (open with the link token)
- vsk=9 → revocation tombstone: the newest event wins, so a tombstone buries
even a stale, still-openable copy on another relay ("a fetcher finds the
grave instead of keys"). Amethyst previously never checked for this, so a
revoked link failed generically.
- anything else present (e.g. a mis-posted registry vsk=8, the shape of the
relayop.xyz link that hung) → unreadable
- nothing on any relay → absent
New pure `ConcordInviteBundle.classify(wraps, token): InviteBundleStatus` in
quartz (next to parse/validate), wrapped by `ConcordActions.classifyInvite`,
and mapped by `Account.joinConcordViaInvite` to the `ConcordInviteResult`
cases — including a new `Revoked` outcome with its own message and no futile
retry. Crypto is unchanged and already matches the spec
(hkdf(token,'concord/invite-key') → NIP-44 → snake_case CommunityInvite).
Adds ConcordInviteClassifyTest covering live / revoked (order-independent) /
unreadable / absent, plus the real relayop.xyz vsk=8 event → Unreadable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KngFNwrQDLYa9QW1f5RRD
`BlossomServerListState` used `Dispatchers.IO` without importing the
common `kotlinx.coroutines.IO` accessor. On JVM/Android the JVM member
resolved fine, but on Kotlin/Native (iosSimulatorArm64) it resolved to
the internal `Dispatchers.IO` member and failed to compile:
Cannot access 'val IO: CoroutineDispatcher': it is internal in
'kotlinx.coroutines.Dispatchers'.
Add the missing `import kotlinx.coroutines.IO`, matching every other
common-source state holder in this module.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtPgFEy9AxoaUT5AqEFBZX
Redeeming a Concord invite link (`…/invite/<naddr>#<fragment>`) that was
minted by a newer/incompatible Concord client left the redeem screen on an
endless "Redeeming invite…" spinner with a retry button that could never
succeed. The bundle event fetches fine, but its content can't be decrypted
(e.g. a bundle tagged `vsk=8` vs the `vsk=6` this app mints/reads), so
`ConcordInviteBundle.parse` returns null and the old `String?` contract
collapsed every failure into a single generic "could not fetch" + retry.
`joinConcordViaInvite` now returns a `ConcordInviteResult` that separates the
outcomes — joined, invalid link, not reachable (transient), and a bundle that
was found but couldn't be opened (incompatible/newer). The invite screen shows
a specific message per case and only offers a retry for the transient miss, so
an invite this app can't open ends on a clear explanation instead of looping.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KngFNwrQDLYa9QW1f5RRD
Audit follow-ups on the DM relay-list work:
- Bug: scheduleOutboxBackfill re-scheduled itself from inside the still-active
drain job, so the isActive guard made the tail call a no-op — any authors
past the first 100-author batch in a burst were stranded until another kind:0
happened to arrive. Drain in a while-loop inside one job instead, and mark it
@Synchronized so concurrent consume-path callers can't spawn duplicate jobs.
- Perf: the back-fill held each REQ open for a fixed 8s. Use fetchAll, which
returns on EOSE (bounded by the timeout), and reuses existing infra.
- DmInboxRelayResolver: drop the redundant `+ cachedOutbox` in the phase-2 seed
(cached outbox is already in phase1Seed, so it was always subtracted back
out), and bound the phase-2 fallback fetch to 5s so a cold send can't stack
two full fan-out timeouts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSc3LhGFSF5VZKr9h3qfn3
Answers "do we load kind:10050 from each user's outbox write relays?" — now we
do, and we make sure we know where those relays are.
- DmInboxRelayResolver.resolve is now two-phase: query the curated indexers ∪
the recipient's known write relays (via a new outboxLookup) for 10050/10002;
if the indexers didn't carry the 10050, read it directly from the write
relays learned from their kind:10002 — the canonical NIP-65 outbox location.
Wired on desktop with cachedAdvertisedRelayList(pubkey).writeRelaysNorm().
- Whenever DesktopLocalCache ingests a user's kind:0, it fires
onProfileMetadataConsumed; the subscriptions coordinator back-fills that
user's kind:10002 (batched ≤100 authors/REQ, deduped, one REQ at a time) and
routes it through consume() so it's saved. So learning who a user is now also
learns where they write.
Tests: DmInboxRelayResolverOutboxTest covers reading 10050 from the recipient's
write relays (indexers only have their 10002) and the cached-outbox seed path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSc3LhGFSF5VZKr9h3qfn3
The account-config bootstrap subscription only queried the default relays,
so a user's Blossom server list (kind 10063) — published to their own write
relays, not the defaults — was never fetched, and the UI fell back to the
default server. NIP-65 relay lists hid the same gap because they're broadcast
widely and also have a local backup.
Add a subscription that re-fetches the account-config kinds (10002/10050/
10007/10006/10063) from the user's NIP-65 outbox (write + untagged relays)
once it's known, routing kind 10063 / 10002 through justConsumeMyOwnEvent
like the bootstrap does. This matches mobile's outbox model: the user's own
data comes from their write relays.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dkzkEY6cUsRfqEb7giHi2
The first pass warmed every conversation peer on disk from the 2s refresh loop.
Scope it to what the user is actually looking at instead:
- Move the prewarm to a reusable, deduped, concurrency-capped
DesktopIAccount.prewarmDmInboxRelays(pubkeys) any DM surface can call.
- Trigger it per-row from the conversation list's LazyColumn, which only
composes visible rows (+ buffer), so peers warm as their row scrolls into
view and no earlier — scrolling warms more.
- Warm a recipient the moment they're picked in the New DM dialog, so the
kind:10050 is ready before the composer opens.
- Room open already resolves peers via ChatNewMessageState.load().
ChatroomListState.prewarmPeerRelays now just delegates to the account.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSc3LhGFSF5VZKr9h3qfn3
- Jump effect: key on the note id alone and always clear the request
after one attempt. Previously keyed on (id, items.list), so a message
arriving mid-jump cancelled the scroll animation and could leave the
request stuck (never cleared when the target wasn't loaded), blocking
repeat taps. Now the animation can't be interrupted and a re-tap always
re-fires.
- Back-fill pinned bodies: add an id-scoped filter for the group's
pinnedEventIds on the host relay. A pin can point at a message older
than the 200-item timeline window, which the h-scoped timeline never
returns — without this the bar shows a blank preview and can't jump.
- Re-invalidate the relay-group filter when the pin list changes (keyed
on the pin ids only, so roster/metadata churn doesn't re-subscribe), so
the new pin ids are actually requested when 39005 arrives.
Replace the threaded blossomServers parameters with a LocalBlossomServers
CompositionLocal, provided once from the account's state holder
(iAccount.blossomServerList.flow) around the logged-in UI. Upload sites read
the list from context instead of receiving it down a parameter chain.
- Provide LocalBlossomServers in MainContent's existing provider (covers
feeds, chats, profile, settings) and around the top-level compose dialog.
- ComposeNoteDialog, EditProfileDialog, ChatPane and the media-server
settings section read LocalBlossomServers.current; drop the params and the
prop-drilling through UserProfileScreen and DesktopMessagesScreen.
- This also covers quote-compose from a feed row (NoteActionsRow), which the
parameter approach couldn't reach — the per-note card composables now get
the list from context, so it no longer defaults to the primal server.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dkzkEY6cUsRfqEb7giHi2
Replace the ICacheProvider.blossomServers(pubKey) cache helper with reads
straight from the account's own state holder — iAccount.blossomServerList.flow
(the shared BlossomServerListState) — threaded to each upload site. This is
the reactive, per-account source of truth and drops the cache+pubkey
indirection entirely.
- Hoist iAccount (with dmSendTracker + accountRelays) out of MainContent into
the LoggedIn branch so the top-level compose dialog can read the account's
blossom flow too; pass them into MainContent as params.
- Thread iAccount.blossomServerList.flow into ComposeNoteDialog (reactive:
the server picker updates if the list loads after the dialog opens),
EditProfileDialog (via UserProfileScreen), and ChatPane (via
DesktopMessagesScreen).
- Reduce BlossomServers.kt to just the DEFAULT_BLOSSOM_SERVER fallback used
when the account has published no kind-10063 list yet.
Known gap: quote-compose opened from a feed row (NoteActionsRow) still
defaults to DEFAULT_BLOSSOM_SERVER — the per-note card composables don't
carry the account handle, and threading it through the whole note-render
tree isn't worth it for that secondary path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dkzkEY6cUsRfqEb7giHi2
A chat bubble reserves ~8dp beneath its last line of text so the reaction
chips, which overlap the bottom border, ride over padding instead of the
text. That reserve was gated on `reactionsRow != null`, but the reaction row
was passed for every non-inner-quote message regardless of whether it had any
engagement — so every footerless bubble (the middle messages of an author
run, which don't show the timestamp) reserved space for an empty chip row,
leaving a visible gap between the text and the bubble's bottom edge.
Gate the chip row on actual engagement via a new `hasChatEngagement` helper
that mirrors the render gate in `RenderChatReactionChips` (reactions, zaps,
pending zaps, minichat replies) and keeps the same live subscriptions, so a
message that gains its first reaction still mounts the row. Footerless
messages with no engagement now hug their text; the reserve returns only when
chips are actually present.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ghZ7egU86LT4Z5BBvyLvY
The unread count, the channel-list/hub last-message previews, and the
Messages-row summary all read a broader note set than the channel feed
renders: the feed's ChannelFeedFilter hides kind-1111 CommentEvent thread
replies (and unacceptable authors), but these row surfaces counted/showed
them. A trailing minichat reply therefore stuck the unread badge at a count
opening the channel could never clear (markAsRead is monotonic and only
advances for rendered timeline messages), and showed up as a "last message"
that isn't on the timeline.
Centralizes the feed's predicate as isConcordTimelineMessage (loaded,
acceptable, not a CommentEvent) plus a newestTimelineNote helper, and routes
the unread count, both list-row previews, and the Messages hub filter through
it — so every channel-row summary agrees with what the open channel renders.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
consumeConcordRumor attaches a message row to its ConcordChannel BEFORE
justConsume loads the event (so the note carries its gatherer through the
Messages filter). That means Channel.addNote ran while createdAt() was still
null and never set lastNote — and on reprojects the containsKey guard skips
addNote entirely, so lastNote stayed null forever for every Concord channel.
The list/hub rows read lastNote, so they always showed "No messages yet" (and
activity sorting was a no-op) even after messages had loaded.
Adds Channel.refreshAfterEventLoad(note), called right after justConsume once
the event is present: it picks lastNote when newer and invalidates the notes
flow so previews, unread counts, and ordering recompute. Regression-tested in
commons (ConcordChannelLastNoteTest).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9Uv3h6GhSQVuUTY5Ffm6e
The kind-10063 list is already the per-account source of truth in the
cache, so the DesktopPreferences.blossomServers singleton was redundant —
and being process-global (not per-account) it was also a latent
account-switch bug: the mirror could hand one account's media servers to
another.
Remove it and have every consumer read the account's list from the cache:
- Add ICacheProvider.blossomServers(pubKey) / preferredBlossomServer(pubKey)
helpers (+ DEFAULT_BLOSSOM_SERVER fallback).
- Upload paths read per-account: ComposeNoteDialog (localCache), ChatPane
(its cacheProvider), EditProfileDialog (localCache threaded from
UserProfileScreen).
- Settings screen falls back to the default constant instead of the pref;
drop the flow→prefs mirror LaunchedEffect.
- Delete DesktopPreferences.blossomServers / preferredBlossomServer.
Uploads require network anyway, by which point the account-config
subscription has loaded kind 10063, so no local persistence is needed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dkzkEY6cUsRfqEb7giHi2
Implements NIP-29 message pinning (nostr-protocol/nips#2379):
Protocol (quartz):
- GroupPinnedEvent (kind 39005): relay-signed pinned-message list, `d`
group id + ordered `e` ids.
- UpdatePinListEvent (kind 9010): moderator `update-pin-list` write,
carries the full list so pin/unpin/reorder/clear are one submission.
- Register both in EventFactory; add a pinnedEventIds tag helper.
Model + cache:
- RelayGroupChannel now folds the pin list (pinnedEventIds / isPinned)
with the same createdAt-supersede guard as the roster.
- LocalCache consumes 39005 into the channel and stores the 9010 write;
39005 added to the group's metadata REQ filter so pins load.
Publish path:
- Account.pin/unpin/updateRelayGroupPins + AccountViewModel wrappers.
UI (non-intrusive, self-hiding):
- Collapsed pinned-message bar under the top bar: shows the current pin,
N-count cycling, tap to jump to the message in-feed (hoisted jump
request threaded through the shared chat feed view). Renders nothing
when the group has no pins.
- Moderator-only Pin/Unpin action under "Show more" in the chat message
bottom drawer, gated on membership.canModerate().
- Small pin glyph on pinned bubbles' footer.
Tests: quartz build/parse round-trip for both kinds; channel pin-fold
supersede/replace/clear semantics.
Builds on the last commit to push the server channel list beyond a plain
messaging list:
- Recent-poster facepile: a stack of overlapping avatars (newest on top) for
each channel's latest distinct authors, so a busy channel reads as "who's
here" at a glance. New ConcordAuthorFacepile reuses the existing avatar
primitives; recentAuthorHexes() picks the most-recent distinct posters in one
O(notes) pass.
- Live "X is typing…": each row consumes the community session's typing
heartbeats (kind 23311) and swaps its preview line for an italic typing
indicator, driven by one screen-level freshness ticker that only spins while a
heartbeat is live.
- Rows are now a two-line layout (name + facepile / preview + time + badge), and
the unread badge animateContentSizes as its count changes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9Uv3h6GhSQVuUTY5Ffm6e
The community "server" channel list (ConcordChannelListScreen) showed only an
icon and name per channel. It now mirrors the chat-room list feel: each row
carries a preview of the last message (author + snippet), the relative time of
that message, and an unread-message count badge, with the name/icon/time
emphasized while unread.
Adds a shared, reactive unread-message counter (concordChannelUnreadCountFlow)
that combines each channel's persisted last-read marker with its own note store,
plus a shared ConcordUnreadBadge pill (capped at 99+, plural content
description). The Concord Channels hub (ConcordHomeScreen) now reuses both:
per-channel rows show the real new-message count instead of a bare dot, and a
community's badge sums new messages across its channels.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9Uv3h6GhSQVuUTY5Ffm6e
- Collect ConcordSession.state via collectAsStateWithLifecycle instead of
reading StateFlow.value in composition (fixes StateFlowValueCalledInComposition
lint error in GroupBottomBarEntries.kt).
- Add @OptIn(ExperimentalCoroutinesApi::class) for testScheduler.advanceTimeBy
and runCurrent usages in ResourceUsageLedgerTest and RelayAuthPromptBusTest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wx8SvzsWsru7rEQ1KcbSCW
The desktop app resolves each recipient's kind:10050 DM inbox (strictly, no
NIP-65 fallback) when sending NIP-17 messages and reactions, but only lazily
at send/room-open time via the indexer fan-out — and recipient relay lists
that arrived through the normal feed were being dropped, so the cache never
helped.
- Route kind:10050 (ChatMessageRelayListEvent) through DesktopLocalCache.route
into addressableNotes, mirroring kind:10002. Previously route() dropped it.
- Back the User model's pinned replaceable notes (kind 10002/10050/10019) with
the same addressableNotes map that consume writes into. Before this, the
desktop UserContext read a plain notes map that nothing populated, so
User.dmInboxRelaysStrict()/outboxRelays() always returned null — leaving the
DM inbox resolver's local lookup, the send-path fallback, and tier-1 AUTH for
the user's own DM relays effectively dead.
- Consume the user's own kind:10050 into LocalCache at bootstrap alongside its
persisted accountRelays copy.
- Prewarm each conversation peer's DM relay list as soon as their room appears
in the list (concurrency-capped, deduped), so the first send/reaction
resolves from cache and the composer's "no DM relays" gate settles early.
Adds DesktopDmRelayListConsumeTest covering kind:10050 routing, newer/older
replacement, and the unknown-recipient case.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSc3LhGFSF5VZKr9h3qfn3
The desktop app read its Blossom media server list only from a local
DesktopPreferences string (defaulting to blossom.primal.net) and never
looked at the user's NIP-B7 BlossomServersEvent (kind 10063) — the same
event the Amethyst mobile app loads via BlossomServerListState. A server
list configured on mobile therefore never showed up on desktop.
Load the list from the network event instead, mirroring the existing
desktop NIP-65 flow:
- Add a shared, platform-agnostic BlossomServerListState in commons that
reads the kind-10063 addressable event from ICacheProvider and exposes
a StateFlow<List<String>> plus a save helper.
- Store incoming kind-10063 events in DesktopLocalCache.route()
(consumeBlossomServerList, newest-per-author wins).
- Instantiate blossomServerList on DesktopIAccount and subscribe to
kind 10063 in the account-config bootstrap subscription.
- Mirror the loaded network list into DesktopPreferences so the upload
path and cold start reflect it; the network event stays authoritative.
- Feed the media-server settings screen from the network list and, on
edit, sign+broadcast a new kind-10063 event so changes sync to every
Amethyst client (writeable accounts only).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dkzkEY6cUsRfqEb7giHi2
Like NIP-17's addRelayToNoteAndInners, propagate the relays a Concord plane
wrap was seen on down to its inner rumor, so a received Concord/Armada
message shows the relays it actually came from — not just the channel's
configured relay set.
The wrap note already carries its seen-on relays (added by consumeRegularEvent
before the gift-wrap handler runs), so GiftWrapEventHandler hands them to
concordSessions.ingest, which threads them through the session/registry to the
rumor sink; LocalCache.consumeConcordRumor then stamps them onto the rumor note
after justConsume. Local-echo sends and buffer re-projections pass no relays
(default empty) since there's no per-wrap relay to attribute there.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX