Compare commits

...

3 Commits

Author SHA1 Message Date
Vitor Pamplona
3a300fa0f6 docs(concord): mark epoch-walking backfill steps 1-3 done
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 17:44:54 -04:00
Vitor Pamplona
446c86c5b6 feat(concord): page channel history across epochs to the true start
The backward "load older" pager REQ'd only the current epoch's Chat Plane, so
deep scroll stopped at the last Refounding and showed "All caught up" while
older messages sat under prior-epoch planes.

Widen the history REQ authors to the union of the channel's plane pubkeys
across every held epoch (ConcordCommunitySession.channelPlaneAddressesAllEpochs).
The relay serves them interleaved by created_at, so one backward `until` sweep
walks the whole cross-Refounding timeline and `exhausted` (the "All caught up"
signal) now means every epoch is drained, not just the current one. The pager
is unchanged — it only tracks until/limit per relay and forwards createdAt; the
prior-epoch wraps decrypt on the normal ingest path (already epoch-aware).

Test: ConcordCommunitySessionTest asserts channelPlaneAddressesAllEpochs returns
current + prior planes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 17:44:21 -04:00
Vitor Pamplona
ca4def18eb feat(concord): backfill prior-epoch channel history from held roots
A CORD-06 Refounding rotates the community_root and bumps the epoch, so each
channel's pre-refounding messages live under a different derived Chat Plane
per epoch. The client only ever subscribed to the current epoch's plane, so
older history was invisible and the feed said "All caught up" while months of
messages sat on the same relays under prior-epoch stream keys.

The account already persists each rotated-out root in
ConcordCommunityListEntry.heldRoots; this consumes them on the read side:

- ConcordActions.historicalChannelPlanes() re-derives each folded channel's
  plane at every held epoch (bounded by MAX_BACKFILL_EPOCHS = 8; 0 disables).
- ConcordCommunitySession keeps a historicalChannelKeysByAddress map (derived
  in refold, since channels are known only after a fold) and folds it into
  channelAddresses() (subscribe), streamKeys() (NIP-42 AUTH), and ingest()
  (decrypt with the matching epoch, isBoundTo per epoch). Channel ids are
  epoch-invariant, so historical messages merge into the same channel feed.
- ConcordSubscriptionPlanner.channelPlaneSubs appends the historical planes,
  so the existing filter assembler subscribes to them unchanged.

Writes / moderation / rekey stay strictly on the current epoch. Cross-validated
against amy: the app now subscribes to the exact prior-epoch plane pubkeys
`amy concord read --epoch 0` proved hold the older Soapbox #nostrhub messages.

Tests: ConcordCommunitySessionTest.ingestsPriorEpochWrapsFromAHeldRoot,
ConcordSubscriptionPlannerTest.channelSubsAlsoCoverPriorEpochPlanesForHeldRoots.

Follow-up (plan step 3): BackwardRelayPager epoch-stepping so deep "load older"
scroll crosses epochs to the true start.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 17:39:33 -04:00
7 changed files with 278 additions and 50 deletions

View File

@@ -101,17 +101,24 @@ class ConcordChannelHistorySubAssembler(
?.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }
?: emptySet()
/** The channel's derived Chat Plane pubkey — the REQ author. Null until the Control Plane folds it. */
private fun planePkFor(key: ConcordChannelHistoryQueryState): String? =
/**
* The channel's derived Chat Plane pubkeys across every epoch (current + each prior epoch we hold
* a root for) — the REQ authors. A CORD-06 Refounding moves the plane per epoch, so requesting the
* union lets one backward `until` sweep walk the whole cross-Refounding timeline and reach messages
* older than the last Refounding. Empty until the Control Plane folds the channel.
*/
private fun planePksFor(key: ConcordChannelHistoryQueryState): List<String> =
key.account.concordSessions
.sessionFor(key.communityId)
?.channelPlaneAddress(key.channelId)
?.channelPlaneAddressesAllEpochs(key.channelId)
.orEmpty()
override fun updateFilter(
key: ConcordChannelHistoryQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter>? {
val planePk = planePkFor(key) ?: return emptyList()
val planePks = planePksFor(key)
if (planePks.isEmpty()) return emptyList()
val relays = relaysFor(key)
// Only armed (advanced, not done) relays carry a REQ, each at its own requested cursor. A parked
// relay keeps the same filter here, so re-assembly (another relay advancing) doesn't re-REQ it.
@@ -124,7 +131,10 @@ class ConcordChannelHistorySubAssembler(
filter =
Filter(
kinds = listOf(ConcordStreamEnvelope.KIND_WRAP),
authors = listOf(planePk),
// All epoch planes at once: the relay serves them interleaved by created_at, so
// one backward cursor walks across the Refounding boundaries; "exhausted" then
// means every epoch is drained, not just the current one.
authors = planePks,
until = until,
limit = pager.pageLimit,
),

View File

@@ -247,11 +247,30 @@ Each covered epoch multiplies the subscription/AUTH footprint by
## Suggested sequence
1. Factor `EpochPlaneSet` derivation + make `ConcordCommunitySession` emit
historical addresses/keys/decrypt (commons unit-tested in isolation — no
network). Ship behind a flag defaulting off.
2. Planner + AUTH wiring; commons tests.
3. amethyst `BackwardRelayPager` epoch-stepping + "All caught up" semantics.
4. ~~`amy --epoch/--root` diagnostic~~ **DONE** (§7); still need a real prior
Soapbox root to validate old-epoch decrypt end-to-end.
5. Flip the flag on; on-device verify on a refounded community.
1. ~~Factor derivation + make `ConcordCommunitySession` emit historical
addresses/keys/decrypt~~ **DONE.** `ConcordActions.historicalChannelPlanes`
(bounded by `MAX_BACKFILL_EPOCHS = 8`; 0 disables) + `HistoricalChannelPlane`;
the session keeps a `historicalChannelKeysByAddress` map derived in `refold()`,
folded into `channelAddresses()` (subscribe), `streamKeys()` (AUTH), and
`ingest()` (decrypt with the matching epoch, `isBoundTo` per epoch). Test:
`ConcordCommunitySessionTest.ingestsPriorEpochWrapsFromAHeldRoot`.
2. ~~Planner + AUTH wiring~~ **DONE.** `ConcordSubscriptionPlanner.channelPlaneSubs`
appends the historical planes → the existing `ConcordChannelFilterAssembler`
subscribes to them with no change; AUTH flows through `session.streamKeys()`.
Test: `ConcordSubscriptionPlannerTest.channelSubsAlsoCoverPriorEpochPlanesForHeldRoots`.
The live channel sub now pulls prior-epoch wraps into `LocalCache`, so
pre-Refounding messages appear on channel open (bounded by relay cap / `since`).
3. ~~amethyst `BackwardRelayPager` epoch-stepping + "All caught up" semantics~~
**DONE.** Rather than step epoch-by-epoch, the history REQ now asks for the
**union** of the channel's plane pubkeys across every held epoch
(`ConcordCommunitySession.channelPlaneAddressesAllEpochs`, used by
`ConcordChannelHistoryFilterAssembler`). The relay serves them interleaved by
`created_at`, so one backward `until` sweep walks the whole cross-Refounding
timeline and `exhausted` ("All caught up") means every epoch is drained. Pager
itself unchanged. No `ConcordChannelScreen` change needed.
4. ~~`amy --epoch/--root` diagnostic~~ **DONE** (§7). Cross-validated: the app now
subscribes to the exact prior-epoch plane pubkeys `amy concord read --epoch 0`
proved hold the older Soapbox #nostrhub messages (identical `publicChannel`
derivation).
5. **TODO** — on-device verify on a refounded community (emulator Concord fold is
historically flaky; verify when it cooperates).

View File

@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord02Community.Guestbook
import com.vitorpamplona.quartz.concord.cord02Community.GuestbookAction
import com.vitorpamplona.quartz.concord.cord02Community.GuestbookEntry
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
@@ -62,6 +63,16 @@ data class ConcordChatMessage(
val epoch: Long,
)
/**
* One channel's Chat Plane at a prior epoch: the epoch-invariant [channelIdHex], the [epoch] the
* wraps are bound to (for `isBoundTo` validation), and the derived [key] to decrypt them.
*/
data class HistoricalChannelPlane(
val channelIdHex: HexKey,
val epoch: Long,
val key: GroupKey,
)
/**
* Concord community verbs — pure builders, plane-key derivation, relay-filter
* assembly, and event folding usable from amy CLI, the Android app, and any other
@@ -87,6 +98,36 @@ object ConcordActions {
rootEpoch: Long,
): GroupKey = ConcordChannelKeys.publicChannel(communityRoot, channelId, rootEpoch)
/**
* How many prior epochs of channel history to backfill. A CORD-06 Refounding rotates the
* `community_root` and bumps the epoch, so pre-refounding messages live under a *different*
* derived Chat Plane per epoch; the client keeps each rotated-out root in
* [ConcordCommunityListEntry.heldRoots]. We re-derive those planes to read the older history
* instead of stopping at the current epoch. Bounded because each covered epoch multiplies the
* subscription + NIP-42 AUTH footprint by (channels); refoundings are rare, so a handful covers
* every real community. Set to 0 to disable historical backfill entirely.
*/
const val MAX_BACKFILL_EPOCHS = 8
/**
* The historical Chat Plane keys for [channelIdsHex] across the prior epochs in [heldRoots]
* (newest-held first, bounded to [MAX_BACKFILL_EPOCHS]). The channel id is epoch-invariant, so a
* message decrypted under a held root lands in the same channel as the current-epoch ones.
*/
fun historicalChannelPlanes(
heldRoots: List<HeldRoot>,
channelIdsHex: Collection<HexKey>,
): List<HistoricalChannelPlane> =
heldRoots
.sortedByDescending { it.epoch }
.take(MAX_BACKFILL_EPOCHS)
.flatMap { held ->
val rootBytes = held.key.hexToByteArray()
channelIdsHex.map { channelIdHex ->
HistoricalChannelPlane(channelIdHex, held.epoch, publicChannel(rootBytes, channelIdHex.hexToByteArray(), held.epoch))
}
}
/** The Guestbook Plane address for a community at [rootEpoch] — where join/leave motions ride. */
fun guestbookPlane(
communityRoot: ByteArray,

View File

@@ -81,21 +81,37 @@ object ConcordSubscriptionPlanner {
)
}
/** Chat-plane subscriptions for every live channel in a folded community [state]. */
/**
* Chat-plane subscriptions for every live channel in a folded community [state] — at the current
* epoch, plus each channel's plane at every prior epoch the account still holds a root for
* ([ConcordCommunityListEntry.heldRoots]). A CORD-06 Refounding rotates the root per epoch, so the
* pre-refounding history lives under those prior-epoch planes; subscribing to them is what lets the
* client fetch messages older than the last Refounding instead of stopping at "All caught up".
*/
fun channelPlaneSubs(
entry: ConcordCommunityListEntry,
state: ConcordCommunityState,
): List<ConcordPlaneSub> {
val root = entry.root.hexToByteArray()
val relays = normalize(entry.relays)
return state.channels.keys.map { channelIdHex ->
val ch = ConcordActions.publicChannel(root, channelIdHex.hexToByteArray(), entry.rootEpoch)
ConcordPlaneSub(
channelId = ConcordChannelId(entry.id, channelIdHex),
pubKeyHex = ch.publicKeyHex,
relays = relays,
)
}
val current =
state.channels.keys.map { channelIdHex ->
val ch = ConcordActions.publicChannel(root, channelIdHex.hexToByteArray(), entry.rootEpoch)
ConcordPlaneSub(
channelId = ConcordChannelId(entry.id, channelIdHex),
pubKeyHex = ch.publicKeyHex,
relays = relays,
)
}
val historical =
ConcordActions.historicalChannelPlanes(entry.heldRoots, state.channels.keys).map { plane ->
ConcordPlaneSub(
channelId = ConcordChannelId(entry.id, plane.channelIdHex),
pubKeyHex = plane.key.publicKeyHex,
relays = relays,
)
}
return current + historical
}
/**

View File

@@ -124,6 +124,12 @@ class ConcordCommunitySession(
// channel plane pubkey -> (channelIdHex, key), refreshed on each control re-fold.
private var channelKeysByAddress = HashMap<HexKey, Pair<HexKey, GroupKey>>()
// Prior-epoch channel plane pubkey -> (channelIdHex, key, epoch), for pre-Refounding history.
// A CORD-06 Refounding rotates the root per epoch, so older messages live under a different
// plane per held root; we re-derive those here so historical wraps are subscribed, AUTHed, and
// decrypted alongside the current epoch. Empty when the account holds no prior roots.
private var historicalChannelKeysByAddress = HashMap<HexKey, Triple<HexKey, GroupKey, Long>>()
private val _state = MutableStateFlow<ConcordCommunityState?>(null)
val state: StateFlow<ConcordCommunityState?> = _state
@@ -185,12 +191,29 @@ class ConcordCommunitySession(
/** The size of [allMembers] — the community's true (best-effort) member count. */
fun memberCount(): Int = allMembers().size
/** The current Chat Plane addresses to subscribe to, one per folded channel. */
fun channelAddresses(): Set<HexKey> = lock.withLock { channelKeysByAddress.keys.toSet() }
/**
* Every Chat Plane address to subscribe to: one per folded channel at the current epoch, plus
* each channel's prior-epoch planes we still hold a root for (pre-Refounding history).
*/
fun channelAddresses(): Set<HexKey> = lock.withLock { channelKeysByAddress.keys + historicalChannelKeysByAddress.keys }
/** The Chat Plane stream address for [channelIdHex], once this community has folded that channel (else null). */
fun channelPlaneAddress(channelIdHex: HexKey): HexKey? = lock.withLock { channelKeysByAddress.entries.firstOrNull { it.value.first == channelIdHex }?.key }
/**
* Every Chat Plane stream address for [channelIdHex] across epochs: the current one plus each
* prior-epoch plane we hold a root for. Used by the history pager as the REQ `authors` set so a
* single backward `until` sweep walks the channel's whole cross-Refounding timeline (older
* messages have smaller `created_at` regardless of epoch), and "All caught up" means every epoch
* is drained — not just the current one. Empty until the Control Plane folds the channel.
*/
fun channelPlaneAddressesAllEpochs(channelIdHex: HexKey): List<HexKey> =
lock.withLock {
val current = channelKeysByAddress.entries.firstOrNull { it.value.first == channelIdHex }?.key
val historical = historicalChannelKeysByAddress.entries.filter { it.value.first == channelIdHex }.map { it.key }
(listOfNotNull(current) + historical)
}
/** The base-rotation rekey [GroupKey] a member opens an inbound Refounding under. */
fun nextBaseRekeyKey(): GroupKey = nextBaseRekeyKey
@@ -212,7 +235,10 @@ class ConcordCommunitySession(
*/
fun streamKeys(): List<GroupKey> =
lock.withLock {
listOf(controlPlaneKey) + channelKeysByAddress.values.map { it.second }
listOf(controlPlaneKey) +
channelKeysByAddress.values.map { it.second } +
// Prior-epoch channel stream keys so the gated relays serve their older wraps too.
historicalChannelKeysByAddress.values.map { it.second }
}
/** The CORD-06 auxiliary plane keys (Guestbook + next base-rekey) for their own isolated AUTH. */
@@ -271,39 +297,58 @@ class ConcordCommunitySession(
return ConcordIngestOutcome.STRUCTURAL
}
else -> {
val channelRef = lock.withLock { channelKeysByAddress[wrap.pubKey] } ?: return ConcordIngestOutcome.NOT_MINE
val (channelIdHex, key) = channelRef
// An ephemeral wrap on a channel plane is a transient signal (typing) — fold it into
// the typing state, never into the stored message buffer or the Note sink. The typing
// UI collects the [typing] StateFlow directly, so this needs no structural revision bump.
if (wrap.kind == ConcordStreamEnvelope.KIND_WRAP_EPHEMERAL) {
ingestTyping(wrap, channelIdHex, key)
return ConcordIngestOutcome.NON_STRUCTURAL
val current = lock.withLock { channelKeysByAddress[wrap.pubKey] }
if (current != null) {
val (channelIdHex, key) = current
return ingestChannelWrap(wrap, channelIdHex, key, entry.rootEpoch, seenOnRelays)
}
val isNew =
lock.withLock {
channelWrapsById.getOrPut(channelIdHex) { LinkedHashMap() }.put(wrap.id, wrap) == null
}
// Project only the newly-arrived wrap — the buffer's earlier wraps were already
// emitted when they landed, so re-decrypting the whole history on every message
// would be O(history) per message (quadratic over a channel's lifetime). A duplicate
// re-delivery (isNew == false) is a no-op. A full-history sweep (member-roster harvest)
// relies on this staying O(1) per wrap.
if (isNew) emitChannelRumors(channelIdHex, key, listOf(wrap), seenOnRelays)
// A chat message lands in the feed via [onRumor] → LocalCache, independent of the
// revision; it changes no plane address, so it must NOT bump (see the storm note above).
return ConcordIngestOutcome.NON_STRUCTURAL
// A prior-epoch plane (pre-Refounding history). Decrypt with that epoch's key and
// bind-check against that epoch. Keyed separately from the current buffer so a re-fold
// (which rebuilds only the current-epoch keys) never re-projects the historical ones.
val historical = lock.withLock { historicalChannelKeysByAddress[wrap.pubKey] } ?: return ConcordIngestOutcome.NOT_MINE
val (channelIdHex, key, epoch) = historical
return ingestChannelWrap(wrap, channelIdHex, key, epoch, seenOnRelays)
}
}
}
/** Shared channel-wrap ingest for any epoch: typing → typing state, else buffer-dedup + emit. */
private fun ingestChannelWrap(
wrap: Event,
channelIdHex: HexKey,
key: GroupKey,
epoch: Long,
seenOnRelays: Set<NormalizedRelayUrl>,
): ConcordIngestOutcome {
// An ephemeral wrap on a channel plane is a transient signal (typing) — fold it into the
// typing state, never the stored buffer or the Note sink. Typing is a current-epoch live
// signal, so a prior-epoch ephemeral (there won't be any — old epochs are frozen) is harmless.
if (wrap.kind == ConcordStreamEnvelope.KIND_WRAP_EPHEMERAL) {
ingestTyping(wrap, channelIdHex, key, epoch)
return ConcordIngestOutcome.NON_STRUCTURAL
}
val isNew =
lock.withLock {
channelWrapsById.getOrPut(channelIdHex) { LinkedHashMap() }.put(wrap.id, wrap) == null
}
// Project only the newly-arrived wrap — the buffer's earlier wraps were already emitted when
// they landed, so re-decrypting the whole history on every message would be O(history) per
// message (quadratic over a channel's lifetime). A duplicate re-delivery (isNew == false) is a
// no-op. A full-history sweep (member-roster harvest) relies on this staying O(1) per wrap.
if (isNew) emitChannelRumors(channelIdHex, key, epoch, listOf(wrap), seenOnRelays)
// A chat message lands in the feed via [onRumor] → LocalCache, independent of the revision; it
// changes no plane address, so it must NOT bump (see the storm note above).
return ConcordIngestOutcome.NON_STRUCTURAL
}
private fun ingestTyping(
wrap: Event,
channelIdHex: HexKey,
key: GroupKey,
epoch: Long,
) {
val rumor = ConcordStreamEnvelope.openOrNull(wrap, key)?.rumor ?: return
if (!ChannelChat.isTyping(rumor) || !ChannelChat.isBoundTo(rumor, channelIdHex, entry.rootEpoch)) return
if (!ChannelChat.isTyping(rumor) || !ChannelChat.isBoundTo(rumor, channelIdHex, epoch)) return
val who = rumor.pubKey.lowercase()
if (who == myPubKey.lowercase()) return // never show my own typing back to me
val now = TimeUtils.now()
@@ -338,6 +383,16 @@ class ConcordCommunitySession(
next[key.publicKeyHex] = channelIdHex to key
}
channelKeysByAddress = next
// Re-derive the prior-epoch planes for the same (epoch-invariant) channel ids, so older
// pre-Refounding history is subscribed/AUTHed/decrypted. Channels are known only after a
// fold, hence derived here rather than up front.
val historical = HashMap<HexKey, Triple<HexKey, GroupKey, Long>>()
for (plane in ConcordActions.historicalChannelPlanes(entry.heldRoots, folded.channels.keys)) {
historical[plane.key.publicKeyHex] = Triple(plane.channelIdHex, plane.key, plane.epoch)
}
historicalChannelKeysByAddress = historical
_state.value = folded
folded.channels.keys.filterNot { it in prevChannels }
}
@@ -356,11 +411,13 @@ class ConcordCommunitySession(
}
}
/** Re-decrypts and re-projects a channel's WHOLE wrap buffer. Only for a re-fold (keys may change). */
/** Re-decrypts and re-projects a channel's WHOLE wrap buffer at the current epoch. Only for a
* re-fold (keys may change). Prior-epoch wraps in the buffer simply won't open under the current
* key and are skipped — they were already emitted when they landed (the sink dedups by id). */
private fun reprojectChannel(channelIdHex: HexKey) {
val key = lock.withLock { channelKeysByAddress.values.firstOrNull { it.first == channelIdHex }?.second } ?: return
val wraps = lock.withLock { channelWrapsById[channelIdHex]?.values?.toList() } ?: return
emitChannelRumors(channelIdHex, key, wraps)
emitChannelRumors(channelIdHex, key, entry.rootEpoch, wraps)
}
/**
@@ -371,11 +428,12 @@ class ConcordCommunitySession(
private fun emitChannelRumors(
channelIdHex: HexKey,
key: GroupKey,
epoch: Long,
wraps: List<Event>,
seenOnRelays: Set<NormalizedRelayUrl> = emptySet(),
) {
val authors = HashSet<HexKey>()
ConcordActions.channelRumors(wraps, key, channelIdHex, entry.rootEpoch).forEach { rumor ->
ConcordActions.channelRumors(wraps, key, channelIdHex, epoch).forEach { rumor ->
authors.add(rumor.pubKey.lowercase())
onRumor(entry.id, channelIdHex, rumor, seenOnRelays)
}

View File

@@ -35,6 +35,38 @@ import kotlin.test.assertTrue
class ConcordSubscriptionPlannerTest {
private val owner = NostrSignerInternal(KeyPair())
@Test
fun channelSubsAlsoCoverPriorEpochPlanesForHeldRoots() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example"))
val priorEpoch = 4L
val priorRoot = KeyPair().pubKey
val entry =
com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry(
id = community.communityIdHex,
owner = community.ownerPubKey,
ownerSalt = community.ownerSalt.toHexKey(),
root = community.communityRoot.toHexKey(),
rootEpoch = community.rootEpoch,
heldRoots =
listOf(
com.vitorpamplona.quartz.concord.cord02Community
.HeldRoot(priorEpoch, priorRoot.toHexKey()),
),
relays = listOf("wss://r.example"),
name = "Nostrichs",
)
val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.ownerPubKey)
val subs = ConcordSubscriptionPlanner.channelPlaneSubs(entry, state)
// Both the current-epoch and the prior-epoch #general planes are subscribed.
val currentGeneral = ConcordActions.publicChannel(community.communityRoot, community.generalChannelId, community.rootEpoch).publicKeyHex
val priorGeneral = ConcordActions.publicChannel(priorRoot, community.generalChannelId, priorEpoch).publicKeyHex
assertTrue(subs.any { it.pubKeyHex == currentGeneral }, "current-epoch plane missing")
assertTrue(subs.any { it.pubKeyHex == priorGeneral }, "prior-epoch plane missing")
assertTrue(currentGeneral != priorGeneral) // a Refounding really does move the plane
}
@Test
fun controlAndChannelSubsMatchDerivedAddresses() =
runTest {

View File

@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.commons.model.concord
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
@@ -35,6 +36,57 @@ import kotlin.test.assertTrue
class ConcordCommunitySessionTest {
private val owner = NostrSignerInternal(KeyPair())
@Test
fun ingestsPriorEpochWrapsFromAHeldRoot() =
runTest {
// A community whose access root has been rotated once (CORD-06 Refounding): the current
// entry is epoch 0/rootA, but the account still holds a prior epoch's root. The prior
// epoch's channel plane is a DIFFERENT stream key; historical backfill must subscribe,
// AUTH, and decrypt it so pre-Refounding messages surface.
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example"))
val priorEpoch = 7L
val priorRoot = KeyPair().pubKey // any 32-byte value is a valid root ikm
val entry =
ConcordCommunityListEntry(
id = community.communityIdHex,
owner = community.ownerPubKey,
ownerSalt = community.ownerSalt.toHexKey(),
root = community.communityRoot.toHexKey(),
rootEpoch = community.rootEpoch,
heldRoots = listOf(HeldRoot(priorEpoch, priorRoot.toHexKey())),
relays = listOf("wss://r.example"),
name = "Nostrichs",
)
val captured = mutableListOf<com.vitorpamplona.quartz.nip01Core.core.Event>()
val session = ConcordCommunitySession(entry, owner.pubKey) { _, _, rumor, _ -> captured += rumor }
// Fold genesis so #general is known — historical planes are derived off the folded channels.
community.genesisWraps.forEach { session.ingest(it) }
// The #general channel plane at the PRIOR epoch (derived from the held root) is now a known
// address AND a stream key to AUTH as.
val priorGeneral = ConcordActions.publicChannel(priorRoot, community.generalChannelId, priorEpoch)
assertTrue(session.channelAddresses().contains(priorGeneral.publicKeyHex), "historical plane not subscribed")
assertTrue(session.streamKeys().any { it.publicKeyHex == priorGeneral.publicKeyHex }, "historical stream key not AUTHed")
// The history pager asks for every epoch's plane at once: current + prior.
val currentGeneral = ConcordActions.publicChannel(community.communityRoot, community.generalChannelId, community.rootEpoch)
val allEpochPlanes = session.channelPlaneAddressesAllEpochs(community.generalChannelIdHex)
assertTrue(allEpochPlanes.contains(currentGeneral.publicKeyHex), "current plane missing from all-epochs")
assertTrue(allEpochPlanes.contains(priorGeneral.publicKeyHex), "prior plane missing from all-epochs")
// A message authored on the prior-epoch plane, bound to the prior epoch, decrypts + emits.
val oldMsg = ConcordActions.buildChannelMessage(owner, priorGeneral, community.generalChannelIdHex, priorEpoch, "gm from the old epoch", 2L)
assertEquals(ConcordIngestOutcome.NON_STRUCTURAL, session.ingest(oldMsg))
assertEquals(1, captured.count { it.content == "gm from the old epoch" })
// A wrap on the prior plane but bound to the WRONG epoch is rejected (no cross-epoch replay).
val spoofed = ConcordActions.buildChannelMessage(owner, priorGeneral, community.generalChannelIdHex, community.rootEpoch, "wrong epoch", 3L)
session.ingest(spoofed)
assertEquals(0, captured.count { it.content == "wrong epoch" })
}
@Test
fun ingestsControlThenChannelWrapsIntoFlows() =
runTest {