fix: load a Concord reply's parent off the channel plane, not by id

A kind-1111 Concord reply that arrives by push notification rendered its
reply-to slot as an unresolvable `nostr:nevent1…`: the parent kind-9 message
was never in the cache and the EventFinder could not fetch it.

A Concord chat message is never published as a bare event. It is an unsigned
rumor sealed inside a kind-1059 wrap authored by the channel's derived plane
key (CORD-03), and its id only comes into existence once that wrap decrypts
locally. So the loader's `{"ids":[<rumor id>]}` REQ could never be served by
any relay — and sending it published a private rumor id to public relays, the
exact thing Note.isPrivateRumor warns against.

- `LocalCache.getChannelToLoadFrom` resolves the room a note belongs to using
  every clue available, not just the event tags `getAnyChannel` reads: the
  gatherer the ingest path attached (the only complete source of a Concord
  ConcordChannelId, whose community half isn't in the rumor), the channel index
  keyed by the `channel` tag, and NIP-29/Buzz relay groups (keyed by host relay
  + group id, which is provenance, not a tag). It also answers for a note whose
  own event never arrived, by borrowing the room from a consumed child — a reply
  or reaction is always in the same room as what it points at.
- `filterMissingConcordRumors` fetches a missing Concord message the only way
  that works: a bounded backward window on the channel plane (all held epochs,
  so it crosses a CORD-06 Refounding), on the community's relays, topped at the
  newest reference we hold. The wraps ingest and decrypt normally, which
  populates the note. `filterMissingEvents` no longer emits ids for these.
- The event loaders now pick relays through `getChannelToLoadFrom`, so an
  unloaded NIP-29/Buzz group message is asked for on the group's host relay
  instead of the account's default set.
- `EventWatcherSubAssembler` skips private rumors: `#e=<rumor id>` can never
  match a wrapped event, and asking leaks the id.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QuYJfe32fxJKUHDYuJuSNa
This commit is contained in:
Claude
2026-07-30 04:17:31 +00:00
parent 1f4d9728be
commit 79d953afe4
8 changed files with 587 additions and 9 deletions
@@ -138,6 +138,7 @@ import com.vitorpamplona.quartz.buzz.wpWorkspaceProfile.SetWorkspaceProfileEvent
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
import com.vitorpamplona.quartz.concord.cord03Channels.concordChannel
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
@@ -1630,6 +1631,63 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
else -> null
}
/**
* The Concord channel a Chat Plane rumor is bound to, found by its `["channel", <id>]` tag alone
* (CORD-03). A rumor commits to only the channel half of a [ConcordChannelId] — the community
* half lives on the session whose key opened the wrap — so the pair is recovered from the channel
* index. Two joined communities would have to have drawn the same random 32-byte channel id for
* this to be ambiguous, so a single match is the answer and anything else counts as unknown.
*/
fun getConcordChannelByChannelId(channelIdHex: HexKey): ConcordChannel? = concordChannels.filter { key, _ -> key.channelId == channelIdHex }.singleOrNull()
/**
* The chat channel a [note] belongs to, using every clue available rather than only the event
* tags [getAnyChannel] reads. This is the "where do I fetch this note from" resolver the event
* loaders ask (see `potentialRelaysToFindEvent`), so it has to answer for the channel kinds whose
* identity is *not* fully in the event:
* - **Concord** — the rumor names its channel but not its community, so the complete
* [ConcordChannelId] only exists on the gatherer the ingest path attached (or, failing that,
* in the channel index keyed by channel id).
* - **NIP-29 / Buzz relay group** — the group is keyed by (host relay, group id) and the host
* relay is the note's provenance, not a tag, so it needs the [Note], not the [Event].
*
* It also answers for a note whose **own event never arrived** — a reply's unloaded parent, the
* usual shape in a notification feed — by borrowing the room from a child that points at it.
*/
fun getChannelToLoadFrom(note: Note): Channel? {
channelOfLoadedNote(note)?.let { return it }
// The event never arrived, so nothing about this note itself says where it lives. Everything
// that points AT it, though, was posted in the same room: a Concord rumor is cryptographically
// bound to its channel and a NIP-29 message carries the group's `h` tag, so a reply or a
// reaction we did consume names the room its parent must be fetched from.
if (note.event == null) {
note.replies.firstNotNullOfOrNull { channelOfLoadedNote(it) }?.let { return it }
note.reactions.values
.firstNotNullOfOrNull { sameKind -> sameKind.firstNotNullOfOrNull { channelOfLoadedNote(it) } }
?.let { return it }
}
return null
}
/** The non-recursive half of [getChannelToLoadFrom]: resolves only from the note's own event/gatherers. */
private fun channelOfLoadedNote(note: Note): Channel? {
// The gatherer the ingest path attached is authoritative (see unlinkAndRemove) and is the only
// complete source for a Concord channel.
note.inGatherers?.firstNotNullOfOrNull { it as? Channel }?.let { return it }
val noteEvent = note.event ?: return null
getAnyChannel(noteEvent)?.let { return it }
noteEvent.tags.concordChannel()?.let { channelIdHex ->
getConcordChannelByChannelId(channelIdHex)?.let { return it }
}
return getRelayGroupChannelForContent(note)
}
/**
* NIP-09 delete of a single targeted event.
*
@@ -38,12 +38,12 @@ fun potentialRelaysToFindAddress(note: AddressableNote): Set<NormalizedRelayUrl>
set.addAll(LocalCache.relayHints.hintsForAddress(note.idHex))
LocalCache.getAnyChannel(note)?.relays()?.let { set.addAll(it) }
LocalCache.getChannelToLoadFrom(note)?.relays()?.let { set.addAll(it) }
note.replyTo?.forEach { parentNote ->
set.addAll(parentNote.relays)
LocalCache.getAnyChannel(parentNote)?.relays()?.let { set.addAll(it) }
LocalCache.getChannelToLoadFrom(parentNote)?.relays()?.let { set.addAll(it) }
parentNote.author?.inboxRelays()?.let { set.addAll(it) }
}
@@ -51,7 +51,7 @@ fun potentialRelaysToFindAddress(note: AddressableNote): Set<NormalizedRelayUrl>
note.replies.forEach { childNote ->
set.addAll(childNote.relays)
LocalCache.getAnyChannel(childNote)?.relays()?.let { set.addAll(it) }
LocalCache.getChannelToLoadFrom(childNote)?.relays()?.let { set.addAll(it) }
childNote.author?.outboxRelays()?.let { set.addAll(it) }
}
@@ -0,0 +1,142 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* How many plane wraps one backfill window pulls. Sized like the Concord catch-up window
* ([com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner.channelPreviewFilters]):
* a thread reply answers something recent, so the message it replies to is a handful of wraps below
* it. A reply to a much older message still opens fine from the message itself the minichat and
* the channel screen mount the channel's backward-history pager, which walks the whole plane.
*/
private const val CONCORD_BACKFILL_LIMIT = 50
/**
* The Concord channel a missing [note] can only be fetched through, or null when it isn't Concord
* content. Also the "never ask a relay for this by id" test [filterMissingEvents] applies.
*/
fun concordChannelToLoadFrom(note: Note): ConcordChannel? = LocalCache.getChannelToLoadFrom(note) as? ConcordChannel
/**
* Re-requests the Concord Chat Plane a missing chat message rode in on.
*
* A Concord message is never published as a bare event: it is an unsigned *rumor* sealed inside a
* kind-1059 wrap authored by the channel's derived plane key (CORD-03), and its id only comes into
* existence once that wrap decrypts locally. So `{"ids":[<rumor id>]}` can never find one no relay
* indexes it and asking would publish a private rumor id to public relays, exactly what
* [Note.isPrivateRumor] warns against. [filterMissingEvents] therefore skips these ids, and this
* fetches them the only way that works: a bounded backward window on the channel plane, on the
* community's relays, ending at the newest thing we hold that references the message. The wraps land
* through the normal ingest path (`concordSessions.ingest` decrypt `consumeConcordRumor`), which
* populates the missing [Note] and lets the reply render its parent.
*
* This is what makes a **reply notification** render its parent: the notification's kind-1111 reply
* arrived over the plane, so it carries the channel and thus the plane keys and relays even though
* we have never seen the message it answers. The plane keys span every held epoch, so the window still
* reaches a parent written before a CORD-06 Refounding.
*/
fun filterMissingConcordRumors(keys: List<EventFinderQueryState>): List<RelayBasedFilter> {
if (keys.isEmpty()) return emptyList()
// Deduped by value: several visible replies to the same unloaded parent — or to parents in the same
// channel whose newest reference lands on the same second — collapse into one REQ per relay instead
// of one per note. [RelayBasedFilter]/[Filter] carry no value equality, hence the explicit key.
val windows = LinkedHashSet<ConcordPlaneWindow>()
keys.forEach { key ->
missingConcordNotes(key).forEach { (note, channel) ->
val planePks =
key.account.concordSessions
.sessionFor(channel.channelId.communityId)
?.channelPlaneAddressesAllEpochs(channel.channelId.channelId)
?.sorted()
// Empty until the Control Plane folds the channel; the always-on plane preload gets us there.
if (planePks.isNullOrEmpty()) return@forEach
val until = untilFor(note)
channel.relays().forEach { relay -> windows.add(ConcordPlaneWindow(relay, planePks, until)) }
}
}
return windows.map { window ->
RelayBasedFilter(
relay = window.relay,
filter =
Filter(
// Stored wraps only — the ephemeral 21059 is a typing heartbeat and carries no history.
kinds = listOf(ConcordStreamEnvelope.KIND_WRAP),
authors = window.planePks,
until = window.until,
limit = CONCORD_BACKFILL_LIMIT,
),
)
}
}
/** One backfill window: the channel's plane keys across every held epoch, on one relay, up to [until]. */
private data class ConcordPlaneWindow(
val relay: NormalizedRelayUrl,
val planePks: List<String>,
val until: Long,
)
/** The unloaded Concord notes this key needs: the note itself and whatever it replies to. */
private fun missingConcordNotes(key: EventFinderQueryState): List<Pair<Note, ConcordChannel>> =
buildList {
addIfMissingConcord(key.note)
key.note.replyTo?.forEach { addIfMissingConcord(it) }
}
private fun MutableList<Pair<Note, ConcordChannel>>.addIfMissingConcord(note: Note) {
if (note is AddressableNote || note.event != null) return
concordChannelToLoadFrom(note)?.let { add(note to it) }
}
/**
* The top of the backfill window: the newest event we hold that points at [note]. A reply or a
* reaction is necessarily younger than what it answers, so the wanted wrap sits at or below that
* instant (`until` is inclusive). Falls back to now when nothing dates the reference and stays a
* pure function of the cache, so re-deriving the filters after EOSE produces the identical REQ
* instead of churning the subscription.
*/
private fun untilFor(note: Note): Long {
val newestReference =
(
note.replies.mapNotNull { it.createdAt() } +
note.reactions.values
.flatten()
.mapNotNull { it.createdAt() }
).maxOrNull()
return newestReference ?: TimeUtils.now()
}
@@ -38,12 +38,16 @@ fun potentialRelaysToFindEvent(note: Note): Set<NormalizedRelayUrl> {
note.author?.outboxRelays()?.let { set.addAll(it) }
LocalCache.getAnyChannel(note)?.relays()?.let { set.addAll(it) }
// getChannelToLoadFrom, not getAnyChannel: a note we have never seen has no event to read a
// channel off, and the two chat kinds keyed outside the event tags (Concord, NIP-29/Buzz relay
// groups) only resolve through the gatherers/provenance it also consults. That is what aims a
// missing group message at its host relay instead of the account's default relay set.
LocalCache.getChannelToLoadFrom(note)?.relays()?.let { set.addAll(it) }
note.replyTo?.forEach { parentNote ->
set.addAll(parentNote.relays)
LocalCache.getAnyChannel(parentNote)?.relays()?.let { set.addAll(it) }
LocalCache.getChannelToLoadFrom(parentNote)?.relays()?.let { set.addAll(it) }
parentNote.author?.inboxRelays()?.let { set.addAll(it) }
}
@@ -51,7 +55,7 @@ fun potentialRelaysToFindEvent(note: Note): Set<NormalizedRelayUrl> {
note.replies.forEach { childNote ->
set.addAll(childNote.relays)
LocalCache.getAnyChannel(childNote)?.relays()?.let { set.addAll(it) }
LocalCache.getChannelToLoadFrom(childNote)?.relays()?.let { set.addAll(it) }
childNote.author?.outboxRelays()?.let { set.addAll(it) }
}
@@ -97,13 +101,21 @@ fun potentialRelaysToFindEvent(note: Note): Set<NormalizedRelayUrl> {
return set
}
/**
* True when [note] can only ever arrive inside a wrapped stream (today: a Concord Chat Plane rumor),
* so an `{"ids":[]}` REQ for it is futile *and* harmful no relay indexes an id that only exists
* after a local decrypt, and asking leaks a private rumor id. [filterMissingConcordRumors] fetches
* these off the plane instead.
*/
private fun isFetchedByPlane(note: Note) = concordChannelToLoadFrom(note) != null
fun filterMissingEvents(keys: List<EventFinderQueryState>): List<RelayBasedFilter> {
val eventsPerRelay =
mapOfSet {
keys.forEach { key ->
val default = key.account.followPlusAllMineWithSearch.flow.value
if (key.note !is AddressableNote && key.note.event == null) {
if (key.note !is AddressableNote && key.note.event == null && !isFetchedByPlane(key.note)) {
potentialRelaysToFindEvent(key.note).ifEmpty { default }.forEach { relayUrl ->
add(relayUrl, key.note.idHex)
}
@@ -115,7 +127,7 @@ fun filterMissingEvents(keys: List<EventFinderQueryState>): List<RelayBasedFilte
// loads threading that is event-based
key.note.replyTo?.forEach { note ->
if (note !is AddressableNote && note.event == null) {
if (note !is AddressableNote && note.event == null && !isFetchedByPlane(note)) {
potentialRelaysToFindEvent(note).ifEmpty { default }.forEach { relayUrl ->
add(relayUrl, note.idHex)
}
@@ -32,6 +32,9 @@ class NoteEventLoaderSubAssembler(
listOfNotNull(
filterMissingEvents(keys),
filterMissingAddressables(keys),
// Concord chat messages have no fetchable id — they only exist inside a plane wrap — so
// they need their own filter shape instead of riding the `ids` REQ above.
filterMissingConcordRumors(keys),
).flatten()
override fun distinct(key: EventFinderQueryState) = key.note
@@ -58,7 +58,14 @@ class EventWatcherSubAssembler(
return null
}
lastNotesOnFilter = keys.map { it.note }
// Private rumors are excluded. A rumor (a Concord chat message, a gift-wrapped DM or private
// reaction) is unsigned and exists only inside its wrap, so no relay indexes its id and
// `#e=<rumor id>` can never return anything — while asking publishes a private rumor id to a
// relay, exactly what Note.isPrivateRumor warns against. Their replies and reactions arrive over
// the same wrapped plane the message itself did.
lastNotesOnFilter = keys.mapNotNull { key -> key.note.takeIf { !it.isPrivateRumor() } }
if (lastNotesOnFilter.isEmpty()) return null
return groupByRelayPresence(lastNotesOnFilter, latestEOSEs)
.map { group ->
@@ -0,0 +1,252 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunitySession
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/**
* A reply notification from a Concord community must be able to render the message it answers.
*
* The reported failure: a kind-1111 Concord reply arrives by push, its parent kind-9 message isn't in
* the cache, and the reply-to slot stays an unresolvable `nostr:nevent1`. The reason is that the
* parent is a *rumor* it only ever exists inside a kind-1059 Chat Plane wrap so the loader's
* `{"ids":[]}` REQ can never find it (and leaks the private rumor id while trying). These tests pin
* the two halves of the fix: the id REQ is not emitted, and a plane window aimed at the community's
* relays is.
*/
class FilterMissingConcordRumorsTest {
private val relay = RelayUrlNormalizer.normalizeOrNull("wss://relay.concord.example/")!!
private val otherRelay = RelayUrlNormalizer.normalizeOrNull("wss://someone-elses-relay.example/")!!
private val communityId = "b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1"
private val channelIdHex = "b4853b67e720d3bbb96afad899648d8403139698bbf3c68826df2c8d1bca4e23"
private val planePk = "cc00cc00cc00cc00cc00cc00cc00cc00cc00cc00cc00cc00cc00cc00cc00cc00"
private val priorEpochPlanePk = "aa00aa00aa00aa00aa00aa00aa00aa00aa00aa00aa00aa00aa00aa00aa00aa00"
/** The reply from the report, verbatim: a kind-1111 comment bound to the channel, unsigned (a rumor). */
private val replyJson =
"""
{
"id": "93da2ffc2efb228b7d956c51ec15f19fcf2a4a0ebb536b4330a16d899d862250",
"pubkey": "5e759c2ca4a4e222ba7af89e6ff315e1d27843fe8bd0a3e7e61e4ba5b1c07326",
"created_at": 1785382199,
"kind": 1111,
"tags": [
["channel", "b4853b67e720d3bbb96afad899648d8403139698bbf3c68826df2c8d1bca4e23"],
["epoch", "2"],
["K", "9"],
["E", "f514388dcfe5e4da8051c363545848e0ecbd55aa618be9658a5385df1344a007", "", "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"],
["P", "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"],
["k", "9"],
["e", "f514388dcfe5e4da8051c363545848e0ecbd55aa618be9658a5385df1344a007", "", "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"],
["p", "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"],
["ms", "62"]
],
"content": "I saw you fixed it in today's update.",
"sig": ""
}
""".trimIndent()
private val parentId = "f514388dcfe5e4da8051c363545848e0ecbd55aa618be9658a5385df1344a007"
private val replyId = "93da2ffc2efb228b7d956c51ec15f19fcf2a4a0ebb536b4330a16d899d862250"
/**
* [LocalCache] is a singleton, so each test has to start from an empty pair of notes otherwise a
* previous test's channel gatherer (a different mock) is still attached to the reply and answers
* for it.
*/
@Before
fun dropCachedNotes() {
LocalCache.notes.remove(parentId)
LocalCache.notes.remove(replyId)
}
/**
* The channel a Concord rumor is attached to at ingest. Mocked rather than folded from a Control
* Plane: all the loaders read off it is its id and its community's relays.
*/
private fun concordChannel() =
mockk<ConcordChannel>(relaxed = true).also {
every { it.channelId } returns ConcordChannelId(communityId, channelIdHex)
every { it.relays() } returns setOf(relay)
}
/**
* Reproduces the post-ingest cache state of the reported notification: the kind-1111 reply loaded
* and attached to its Concord channel, its parent a bare id-only [Note]. Returns the parent.
*/
private fun landReplyWithUnloadedParent(channel: ConcordChannel?): Note {
val replyEvent = Event.fromJson(replyJson)
val parent = LocalCache.getOrCreateNote(parentId)
val reply = LocalCache.getOrCreateNote(replyEvent.id)
reply.loadEvent(replyEvent, LocalCache.getOrCreateUser(replyEvent.pubKey), listOf(parent))
// The wrap it rode in on was seen here, so this is the only relay the reply itself points at.
reply.addRelay(relay)
parent.addReply(reply)
channel?.let { reply.addGatherer(it) }
return parent
}
/**
* An account with no relay lists of its own, so the only relays any filter can name are the ones
* the notes/channel supply which is what these assertions are about.
*/
private fun stubAccount(): Account {
val account = mockk<Account>(relaxed = true)
every { account.followPlusAllMineWithSearch.flow } returns MutableStateFlow(emptySet())
every { account.searchRelayList.flow } returns MutableStateFlow(emptySet())
return account
}
/** An account whose Control Plane has folded this channel, so its plane keys are derivable. */
private fun accountWithFoldedChannel(): Account {
val account = stubAccount()
val session = mockk<ConcordCommunitySession>(relaxed = true)
every { account.concordSessions.sessionFor(communityId) } returns session
// Every held epoch's plane, so the window reaches across a CORD-06 Refounding.
every { session.channelPlaneAddressesAllEpochs(channelIdHex) } returns listOf(planePk, priorEpochPlanePk)
return account
}
private fun accountWithUnfoldedChannel(): Account {
val account = stubAccount()
every { account.concordSessions.sessionFor(any()) } returns null
return account
}
@Test
fun `the channel of an unloaded parent is borrowed from the reply that arrived over its plane`() {
val channel = concordChannel()
val parent = landReplyWithUnloadedParent(channel)
assertSame("the parent's room comes from its consumed reply", channel, LocalCache.getChannelToLoadFrom(parent))
assertSame(channel, concordChannelToLoadFrom(parent))
}
@Test
fun `a missing Concord parent is never requested by id`() {
val channel = concordChannel()
val parent = landReplyWithUnloadedParent(channel)
val account = accountWithFoldedChannel()
val keys = listOf(EventFinderQueryState(parent, account))
val ids = filterMissingEvents(keys).flatMap { it.filter.ids.orEmpty() }
assertTrue("a rumor id must never be sent to a relay, it cannot be served: $ids", ids.isEmpty())
}
@Test
fun `a missing Concord parent is fetched as a plane window on the community relays`() {
val channel = concordChannel()
val parent = landReplyWithUnloadedParent(channel)
val account = accountWithFoldedChannel()
val keys = listOf(EventFinderQueryState(parent, account))
val filters = filterMissingConcordRumors(keys)
assertEquals(1, filters.size)
assertEquals(relay, filters[0].relay)
val filter = filters[0].filter
assertEquals(listOf(ConcordStreamEnvelope.KIND_WRAP), filter.kinds)
assertEquals(listOf(planePk, priorEpochPlanePk).sorted(), filter.authors)
// The window tops out at the reply — the message it answers is necessarily at or below it.
assertEquals(1785382199L, filter.until)
assertEquals(50, filter.limit)
assertNull("a plane window is never an ids REQ", filter.ids)
}
@Test
fun `the reply's own key backfills its parent, which is how a notification card loads it`() {
// The EventFinder is mounted on the note the card renders — the reply — not on the parent.
val channel = concordChannel()
val parent = landReplyWithUnloadedParent(channel)
val reply = parent.replies.single()
val keys = listOf(EventFinderQueryState(reply, accountWithFoldedChannel()))
assertEquals(1, filterMissingConcordRumors(keys).size)
assertTrue(filterMissingEvents(keys).flatMap { it.filter.ids.orEmpty() }.isEmpty())
}
@Test
fun `nothing is requested until the Control Plane folds the channel`() {
val parent = landReplyWithUnloadedParent(concordChannel())
val keys = listOf(EventFinderQueryState(parent, accountWithUnfoldedChannel()))
assertTrue(filterMissingConcordRumors(keys).isEmpty())
}
@Test
fun `a plain missing parent is still fetched by id`() {
// Guard against over-blocking: only Concord content skips the ids REQ.
val parent = landReplyWithUnloadedParent(channel = null)
val keys = listOf(EventFinderQueryState(parent, accountWithUnfoldedChannel()))
val byRelay = filterMissingEvents(keys)
assertEquals(listOf(relay), byRelay.map { it.relay })
assertEquals(listOf(listOf(parentId)), byRelay.map { it.filter.ids })
assertTrue(filterMissingConcordRumors(keys).isEmpty())
}
@Test
fun `two replies to the same unloaded parent collapse into one plane window`() {
val channel = concordChannel()
val parent = landReplyWithUnloadedParent(channel)
val account = accountWithFoldedChannel()
val reply = parent.replies.single()
val keys =
listOf(
EventFinderQueryState(parent, account),
EventFinderQueryState(reply, account),
)
assertEquals(1, filterMissingConcordRumors(keys).size)
}
@Test
fun `an unrelated relay is not asked for the plane`() {
val channel = concordChannel()
every { channel.relays() } returns setOf(relay)
val parent = landReplyWithUnloadedParent(channel)
val keys = listOf(EventFinderQueryState(parent, accountWithFoldedChannel()))
assertTrue(filterMissingConcordRumors(keys).none { it.relay == otherRelay })
}
}
@@ -0,0 +1,104 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Before
import org.junit.Test
/**
* A NIP-29 / Buzz group's history lives on ONE relay its host and nowhere else, so the id REQ for
* an unloaded message in that group has to name that relay. The group is keyed by (host relay, group
* id), which is not derivable from the event's tags alone, so the loader has to resolve it through the
* channel the ingest path attached (`LocalCache.getChannelToLoadFrom`) rather than the tag-only
* `getAnyChannel`. Without that, a message whose reply reached us from a mirror or with no relay
* provenance at all is only ever asked for on the account's default relays, which don't carry the
* group.
*/
class FilterMissingEventsChannelRelaysTest {
private val hostRelay = RelayUrlNormalizer.normalizeOrNull("wss://buzz.host.example/")!!
private val mirrorRelay = RelayUrlNormalizer.normalizeOrNull("wss://mirror.example/")!!
private val parentId = "1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a"
private val replyId = "2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b"
/** A NIP-29 kind-9 group message answering [parentId], scoped to group `g1` by its `h` tag. */
private val replyJson =
"""
{
"id": "$replyId",
"pubkey": "5e759c2ca4a4e222ba7af89e6ff315e1d27843fe8bd0a3e7e61e4ba5b1c07326",
"created_at": 1785382199,
"kind": 9,
"tags": [
["h", "g1"],
["e", "$parentId", "", "reply"]
],
"content": "same question here",
"sig": ""
}
""".trimIndent()
@Before
fun dropCachedNotes() {
LocalCache.notes.remove(parentId)
LocalCache.notes.remove(replyId)
}
private fun stubAccount(): Account {
val account = mockk<Account>(relaxed = true)
every { account.followPlusAllMineWithSearch.flow } returns MutableStateFlow(emptySet())
every { account.searchRelayList.flow } returns MutableStateFlow(emptySet())
every { account.concordSessions.sessionFor(any()) } returns null
return account
}
@Test
fun `an unloaded group parent is asked for on the group's host relay, not only where the reply came from`() {
val group = RelayGroupChannel(GroupId("g1", hostRelay))
val replyEvent = Event.fromJson(replyJson)
val parent = LocalCache.getOrCreateNote(parentId)
val reply = LocalCache.getOrCreateNote(replyId)
reply.loadEvent(replyEvent, LocalCache.getOrCreateUser(replyEvent.pubKey), listOf(parent))
// The reply reached us from a mirror; only the group's channel knows where the group lives.
reply.addRelay(mirrorRelay)
parent.addReply(reply)
reply.addGatherer(group)
assertSame("the parent's group comes from its consumed reply", group, LocalCache.getChannelToLoadFrom(parent))
val filters = filterMissingEvents(listOf(EventFinderQueryState(parent, stubAccount())))
assertEquals(setOf(hostRelay, mirrorRelay), filters.map { it.relay }.toSet())
filters.forEach { assertEquals(listOf(parentId), it.filter.ids) }
}
}