Merge pull request #3622 from vitorpamplona/claude/concord-epoch-backfill

feat(concord): backfill channel history across Refounding epochs
This commit is contained in:
Vitor Pamplona
2026-07-17 18:04:16 -04:00
committed by GitHub
7 changed files with 278 additions and 50 deletions
@@ -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,
),
@@ -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).
@@ -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,
@@ -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
}
/**
@@ -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)
}
@@ -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 {
@@ -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 {