feat(concord): refuse Control-Plane rollbacks with a version floor

`ConcordRefounding.compactControlPlane` re-wraps one edition per entity
when a community rotates epoch, and the ROTATOR chooses which one
survives. The receiving side had no memory: `refold()` folds only the
wraps at the current epoch's Control-Plane address and discards the prior
epoch's buffer, and `EditionFold` accepts whatever it is handed (its
no-genesis fallback anchors at the lowest version present).

So a rotator could publish only version 1 of a chain and omit version 2 —
restoring a revoked role, clearing a banlist, reverting metadata. Every
signature is genuine; this is rollback by omission, not forgery.

Adds a per-entity floor: the version AND hash last successfully folded.

- **No floor (fresh joiner)** — unchanged: genesis anchor, else the
  lowest-version edition as the legitimate compaction bootstrap.
- **With a floor** — the walk is anchored AT the floor: the offered set
  must contain the exact edition already folded (version and hash; a
  same-version sibling is a fork, not our chain), then walks up. A head
  below the floor is structurally unreachable.
- **Gap** (the floor edition is absent) — refuse, and keep the known
  head. Refusing by *retaining* matters here: this fold is recomputed
  from scratch each time, so letting an entity vanish would itself be a
  rollback — a dropped banlist is an unban.

The floor needs no new persistence. It is derived from `heldRoots`, the
rotated-out access roots already persisted in the NIP-44 self-encrypted
kind-13302 list: the session derives each prior epoch's Control-Plane
address from them, folds oldest-first, and takes the resulting heads as
the floor. That survives both a process restart and the session rebuild
`ConcordSessionRegistry.sync` performs at exactly the moment of a
Refounding — which would have destroyed any in-session floor. If the old
planes are not served, there is no floor and behaviour is as before.

Floors are built from AUTHORITY-GATED heads, not raw ones. Without that,
any ex-member still holding a rotated-out root could mint a high-version
edition on the old plane and freeze the entity for every honest client —
a denial of service this change would otherwise have introduced. Covered
by a test.

Verified by disabling both enforcement points: 7 of 12 quartz tests and
the end-to-end commons test fail, and the ones that still pass are
exactly the non-regression cases (fresh joiner, honest compaction,
pass-through without floors).

Known limit: `AuthorityResolver.resolve` folds authorized SUBSETS of the
edition pool and does not carry floors itself; gating happens at the pool
level before the resolver sees anything. Sound, but connectivity checked
on the full set is a weaker precondition than on each subset — passing
floors into the resolver's three folds is worth a follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-07-19 20:36:25 -04:00
co-authored by Claude Opus 4.8
parent 30f4638954
commit fac1bf5b5d
7 changed files with 729 additions and 25 deletions
@@ -55,11 +55,31 @@ data class ConcordPlaneSub(
* ([channelPlaneSubs]).
*/
object ConcordSubscriptionPlanner {
/** Control-plane subscriptions for every joined community (known from the entry alone). */
/**
* Control-plane subscriptions for every joined community (known from the entry alone) — at
* the current epoch **plus** every prior epoch the account still holds a root for.
*
* The prior-epoch Control Planes are what a CORD-06 Refounding's compacted head is checked
* against: the rotator picks which edition per entity survives the rotation, so without the
* old planes a client has no memory of the versions it already folded and a rotator can roll
* an entity backwards (restore a revoked role, clear a banlist) with genuine signatures. See
* `ConcordCommunitySession.historicalControlPlaneAddresses`.
*/
fun controlPlaneSubs(entries: List<ConcordCommunityListEntry>): List<ConcordPlaneSub> =
entries.map { e ->
val cp = ConcordActions.controlPlane(e.root.hexToByteArray(), e.id.hexToByteArray(), e.rootEpoch)
ConcordPlaneSub(channelId = null, pubKeyHex = cp.publicKeyHex, relays = normalize(e.relays))
entries.flatMap { e ->
val communityId = e.id.hexToByteArray()
val relays = normalize(e.relays)
val cp = ConcordActions.controlPlane(e.root.hexToByteArray(), communityId, e.rootEpoch)
val historical =
e.heldRoots
.filter { it.epoch < e.rootEpoch }
.sortedByDescending { it.epoch }
.take(ConcordActions.MAX_BACKFILL_EPOCHS)
.mapNotNull { held ->
val key = runCatching { ConcordActions.controlPlane(held.key.hexToByteArray(), communityId, held.epoch) }.getOrNull() ?: return@mapNotNull null
ConcordPlaneSub(channelId = null, pubKeyHex = key.publicKeyHex, relays = relays)
}
listOf(ConcordPlaneSub(channelId = null, pubKeyHex = cp.publicKeyHex, relays = relays)) + historical
}
/**
@@ -27,6 +27,8 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntr
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.EditionFold
import com.vitorpamplona.quartz.concord.cord04Roles.EntityFloor
import com.vitorpamplona.quartz.concord.crypto.GroupKey
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -113,10 +115,40 @@ class ConcordCommunitySession(
/** The next-epoch base-rekey stream address to watch for an inbound Refounding. */
val nextBaseRekeyAddress: HexKey get() = nextBaseRekeyKey.publicKeyHex
/**
* The Control Plane of every **prior** epoch we still hold a root for (address ->
* key + epoch), newest-held first and bounded like the channel backfill.
*
* This is the anti-rollback memory. A CORD-06 Refounding re-wraps one edition per
* entity at the new epoch and the *rotator* chooses which one, so it can serve v1
* of a chain that had already reached v2 — restoring a revoked role, clearing a
* banlist — with every signature genuine. Folding the epochs we still hold roots
* for gives us the [EntityFloor] each entity must connect to, and because
* `heldRoots` is already persisted in the kind-13302 community list, that memory
* survives a process restart without any new storage.
*/
private val historicalControlKeys: Map<HexKey, Pair<GroupKey, Long>> =
entry.heldRoots
.filter { it.epoch < entry.rootEpoch }
.sortedByDescending { it.epoch }
.take(ConcordActions.MAX_BACKFILL_EPOCHS)
.mapNotNull { held ->
val key = runCatching { ConcordActions.controlPlane(held.key.hexToByteArray(), communityIdBytes, held.epoch) }.getOrNull() ?: return@mapNotNull null
key.publicKeyHex to (key to held.epoch)
}.toMap()
/** The prior-epoch Control Plane addresses to subscribe to, so the rollback floor can be rebuilt. */
fun historicalControlPlaneAddresses(): Set<HexKey> = historicalControlKeys.keys
private val lock = KmpLock()
// Deduped inbound wraps.
private val controlWraps = LinkedHashMap<HexKey, Event>()
// Prior-epoch Control Plane address -> (wrapId -> wrap). Kept apart from [controlWraps]: these
// never join the live fold, they only produce the anti-rollback floor.
private val historicalControlWraps = HashMap<HexKey, LinkedHashMap<HexKey, Event>>()
private val channelWrapsById = HashMap<HexKey, LinkedHashMap<HexKey, Event>>() // channelIdHex -> (wrapId -> wrap)
private val guestbookWraps = LinkedHashMap<HexKey, Event>()
private val baseRekeyWraps = LinkedHashMap<HexKey, Event>()
@@ -236,6 +268,9 @@ class ConcordCommunitySession(
fun streamKeys(): List<GroupKey> =
lock.withLock {
listOf(controlPlaneKey) +
// Prior-epoch Control Planes: the anti-rollback floor is folded from them, so the
// gated relays must serve their wraps too.
historicalControlKeys.values.map { it.first } +
channelKeysByAddress.values.map { it.second } +
// Prior-epoch channel stream keys so the gated relays serve their older wraps too.
historicalChannelKeysByAddress.values.map { it.second }
@@ -297,6 +332,17 @@ class ConcordCommunitySession(
return ConcordIngestOutcome.STRUCTURAL
}
else -> {
// A prior-epoch Control Plane wrap: buffer it and re-fold, so the anti-rollback
// floor rises as the old epochs drain in. Structural — the floor can change the
// folded state (and therefore the plane set) exactly like a live control wrap.
if (wrap.pubKey in historicalControlKeys) {
lock.withLock {
val buffer = historicalControlWraps.getOrPut(wrap.pubKey) { LinkedHashMap() }
if (buffer.put(wrap.id, wrap) != null) return ConcordIngestOutcome.NON_STRUCTURAL // dup
}
refold()
return ConcordIngestOutcome.STRUCTURAL
}
val current = lock.withLock { channelKeysByAddress[wrap.pubKey] }
if (current != null) {
val (channelIdHex, key) = current
@@ -374,7 +420,12 @@ class ConcordCommunitySession(
val newChannels =
lock.withLock {
val wraps = controlWraps.values.toList()
val folded = ConcordActions.foldCommunity(wraps, controlPlaneKey, entry.owner)
val folded =
ConcordCommunityState.fold(
ConcordActions.controlEditions(wraps, controlPlaneKey),
entry.owner,
controlFloorsLocked(),
)
val prevChannels = channelKeysByAddress.values.mapTo(HashSet()) { it.first }
val next = HashMap<HexKey, Pair<HexKey, GroupKey>>()
@@ -404,6 +455,30 @@ class ConcordCommunitySession(
for (channelIdHex in newChannels) reprojectChannel(channelIdHex)
}
/**
* The per-entity anti-rollback floor: the authority-gated heads of every prior epoch's
* Control Plane we still hold a root for, folded **oldest epoch first** so each epoch is
* itself anchored at the one before it and the floor only ever rises.
*
* The current epoch must then connect to these heads; an entity whose offered chain cannot
* reach its floor keeps the state we last folded (see [EditionFold.admissible]) and the
* refusal is warned. Empty for a fresh joiner (no held roots), which is exactly right — it
* legitimately has no history and must still accept the compacted head as its baseline.
*
* Caller must hold [lock]; a fold reads the wrap buffers.
*/
private fun controlFloorsLocked(): Map<String, EntityFloor> {
if (historicalControlKeys.isEmpty()) return emptyMap()
var floors = emptyMap<String, EntityFloor>()
for ((address, keyAtEpoch) in historicalControlKeys.entries.sortedBy { it.value.second }) {
val wraps = historicalControlWraps[address]?.values?.toList() ?: continue
val editions = ConcordActions.controlEditions(wraps, keyAtEpoch.first)
if (editions.isEmpty()) continue
floors = ConcordCommunityState.authorizedHeads(editions, entry.owner, floors)
}
return floors
}
private fun refoldGuestbook() {
lock.withLock {
val wraps = guestbookWraps.values.toList()
@@ -93,6 +93,9 @@ class ConcordSessionRegistry(
val out = HashSet<HexKey>()
for (session in sessions.values) {
out += session.controlPlaneAddress
// Prior-epoch Control Planes too: folding them is what gives each entity its
// anti-rollback floor across a CORD-06 Refounding.
out += session.historicalControlPlaneAddresses()
out += session.channelAddresses()
}
out
@@ -0,0 +1,233 @@
/*
* 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.commons.model.concord
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.commons.actions.ConcordModeration
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Session-level anti-rollback (CORD-06 §3).
*
* `ConcordRefounding.compactControlPlane` re-wraps ONE edition per entity when a community
* rotates its root, and the ROTATOR picks which one. A rotator that simply omits the newest
* edition of a chain walks the entity backwards — a revoked role restored, a banlist cleared,
* metadata reverted — with every signature genuine. The defense is memory: the account already
* persists the rotated-out roots (`heldRoots`, in the NIP-44 self-encrypted kind-13302 list), so
* the session re-derives each prior epoch's Control Plane, folds it, and requires the new epoch's
* chain to connect to the heads it already knew.
*/
class ConcordRollbackFloorTest {
private val owner = NostrSignerInternal(KeyPair())
@Test
fun sessionRefusesAMetadataRollbackAcrossARefounding() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example"))
// Epoch 0: genesis metadata (v0 "Nostrichs") plus an owner rename (v1 "Nostrichs HQ").
val genesisEditions = ConcordActions.controlEditions(community.genesisWraps, community.controlPlane)
val rename =
ConcordModeration.editMetadata(
actor = owner,
controlPlane = community.controlPlane,
communityId = community.communityId,
metadata = MetadataEntity(name = "Nostrichs HQ"),
current = genesisEditions,
createdAt = 2L,
)
val epoch0Wraps = community.genesisWraps + rename
// The rotator refounds, but compacts from the genesis subset ONLY — the rename (v1) is
// silently dropped. Every wrap it publishes is a genuine, owner-signed edition.
val newRoot = ByteArray(32) { 0x33 }
val newEpoch = community.rootEpoch + 1
val newControl = ConcordActions.controlPlane(newRoot, community.communityId, newEpoch)
val rolledBack = ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl)
val entry =
ConcordCommunityListEntry(
id = community.communityIdHex,
owner = community.ownerPubKey,
ownerSalt = community.ownerSalt.toHexKey(),
root = newRoot.toHexKey(),
rootEpoch = newEpoch,
heldRoots = listOf(HeldRoot(community.rootEpoch, community.communityRoot.toHexKey())),
relays = listOf("wss://r.example"),
name = "Nostrichs",
)
val session = ConcordCommunitySession(entry, owner.pubKey)
// The prior epoch's Control Plane is subscribed and AUTHed for — that is where the floor
// comes from, and without it the client has no memory to check the rotator against.
assertTrue(
session.historicalControlPlaneAddresses().contains(community.controlPlane.publicKeyHex),
"prior-epoch control plane not subscribed",
)
assertTrue(
session.streamKeys().any { it.publicKeyHex == community.controlPlane.publicKeyHex },
"prior-epoch control plane not AUTHed",
)
// Feed the rolled-back new epoch first, then the prior epoch drains in.
rolledBack.forEach { session.ingest(it) }
epoch0Wraps.forEach { session.ingest(it) }
assertEquals(
"Nostrichs HQ",
session.state.value
?.metadata
?.name,
"the rollback to v0 must be refused",
)
}
@Test
fun sessionAdoptsAnHonestCompaction() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example"))
val genesisEditions = ConcordActions.controlEditions(community.genesisWraps, community.controlPlane)
val rename =
ConcordModeration.editMetadata(
actor = owner,
controlPlane = community.controlPlane,
communityId = community.communityId,
metadata = MetadataEntity(name = "Nostrichs HQ"),
current = genesisEditions,
createdAt = 2L,
)
val epoch0Wraps = community.genesisWraps + rename
val newRoot = ByteArray(32) { 0x33 }
val newEpoch = community.rootEpoch + 1
val newControl = ConcordActions.controlPlane(newRoot, community.communityId, newEpoch)
// Honest: compacted from the FULL prior plane, so each entity's head (metadata v1) survives.
val honest = ConcordRefounding.compactControlPlane(epoch0Wraps, community.controlPlane, newControl)
val entry =
ConcordCommunityListEntry(
id = community.communityIdHex,
owner = community.ownerPubKey,
ownerSalt = community.ownerSalt.toHexKey(),
root = newRoot.toHexKey(),
rootEpoch = newEpoch,
heldRoots = listOf(HeldRoot(community.rootEpoch, community.communityRoot.toHexKey())),
relays = listOf("wss://r.example"),
name = "Nostrichs",
)
val session = ConcordCommunitySession(entry, owner.pubKey)
epoch0Wraps.forEach { session.ingest(it) }
honest.forEach { session.ingest(it) }
val state = session.state.value
assertEquals("Nostrichs HQ", state?.metadata?.name)
assertTrue(state!!.channels.containsKey(community.generalChannelIdHex), "#general must survive an honest compaction")
}
/**
* A fresh joiner holds no prior root, so it holds no floor: the dangling compacted head IS its
* baseline (CORD-04 §1). The floor must never regress this — that regression is what hid a
* refounded community's name, icon and channels.
*/
@Test
fun freshJoinerWithNoHeldRootsStillFoldsACompactedPlane() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example"))
val newRoot = ByteArray(32) { 0x33 }
val newEpoch = community.rootEpoch + 1
val newControl = ConcordActions.controlPlane(newRoot, community.communityId, newEpoch)
val compacted = ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl)
val entry =
ConcordCommunityListEntry(
id = community.communityIdHex,
owner = community.ownerPubKey,
ownerSalt = community.ownerSalt.toHexKey(),
root = newRoot.toHexKey(),
rootEpoch = newEpoch,
relays = listOf("wss://r.example"),
name = "Nostrichs",
)
val session = ConcordCommunitySession(entry, owner.pubKey)
assertTrue(session.historicalControlPlaneAddresses().isEmpty())
compacted.forEach { session.ingest(it) }
assertEquals(
"Nostrichs",
session.state.value
?.metadata
?.name,
)
assertTrue(
session.state.value!!
.channels
.containsKey(community.generalChannelIdHex),
)
}
/**
* The floor is only as trustworthy as the editions it is built from. Any ex-member still holds
* a rotated-out root and could mint a high-version edition on that old Control Plane; if the
* floor were taken from an ungated fold, that would freeze the entity for every honest client.
* [ConcordCommunityState.authorizedHeads] gates the same way the live fold does, so an
* unprivileged author raises no floor.
*/
@Test
fun anUnprivilegedEditionOnAnOldPlaneRaisesNoFloor() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example"))
val rogue = NostrSignerInternal(KeyPair())
val genesisEditions = ConcordActions.controlEditions(community.genesisWraps, community.controlPlane)
// The rogue holds the (rotated-out) root, so it can publish a well-formed v1 metadata
// edition — it just has no MANAGE_METADATA and no owner-rooted grant.
val rogueEdit =
ConcordModeration.editMetadata(
actor = rogue,
controlPlane = community.controlPlane,
communityId = community.communityId,
metadata = MetadataEntity(name = "Hijacked"),
current = genesisEditions,
createdAt = 2L,
)
val floors =
ConcordCommunityState.authorizedHeads(
ConcordActions.controlEditions(community.genesisWraps + rogueEdit, community.controlPlane),
community.ownerPubKey,
)
val metadataFloor = floors[community.communityIdHex]
assertEquals(0L, metadataFloor?.version, "an unauthorized edition must not raise the floor")
}
}
@@ -27,8 +27,10 @@ import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
import com.vitorpamplona.quartz.concord.cord04Roles.EditionFold
import com.vitorpamplona.quartz.concord.cord04Roles.EntityFloor
import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity
import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
import com.vitorpamplona.quartz.concord.cord04Roles.asFloor
/** A channel id paired with its current folded definition. */
class ConcordChannel(
@@ -56,11 +58,69 @@ class ConcordCommunityState(
val dissolved: Boolean,
) {
companion object {
/**
* The permission bit an edition of each entity kind must be authored under.
* `null` means owner-only (no bit grants it). Mirrors the per-kind gating
* [fold] applies before the structural fold.
*/
private fun requiredPermission(kind: ControlEntityKind): Int? =
when (kind) {
ControlEntityKind.METADATA -> ConcordPermissions.MANAGE_METADATA
ControlEntityKind.CHANNEL -> ConcordPermissions.MANAGE_CHANNELS
ControlEntityKind.ROLE, ControlEntityKind.GRANT -> ConcordPermissions.MANAGE_ROLES
ControlEntityKind.BANLIST -> ConcordPermissions.BAN
ControlEntityKind.INVITE_LIVE, ControlEntityKind.INVITE_REGISTRY, ControlEntityKind.INVITE_REVOKED -> ConcordPermissions.CREATE_INVITE
ControlEntityKind.DISSOLVED -> null
}
/**
* The authority-gated structural head of **every** control entity, keyed by
* [ControlEdition.entityIdHex] — the source of the anti-rollback [EntityFloor]s a
* client carries across a CORD-06 Refounding.
*
* It is deliberately gated the same way [fold] gates each entity kind (and, for
* [ControlEntityKind.DISSOLVED], owner-only): an *ungated* head map would let any
* ex-member who still holds a rotated-out root mint a high-version edition on the
* old Control Plane and thereby raise our floor, freezing the entity for us. The
* floor must only ever remember editions we would actually have honored.
*/
fun authorizedHeads(
editions: Collection<ControlEdition>,
ownerPubKey: String,
floors: Map<String, EntityFloor> = emptyMap(),
): Map<String, EntityFloor> {
val pool = EditionFold.admissible(editions, floors)
val authority = AuthorityResolver.resolve(pool, ownerPubKey)
val out = HashMap<String, EntityFloor>(floors)
for ((kind, list) in pool.groupBy { it.entityKind }) {
val bit = requiredPermission(kind)
val gated =
list.filter {
authority.isOwner(it.author) || (bit != null && authority.hasPermission(it.author, bit))
}
for ((entity, head) in EditionFold.fold(gated, floors)) {
// Monotonic: a floor only ever rises. Folding epoch by epoch, an entity the
// newer epoch never mentions keeps the version the older one reached.
val prior = out[entity]
if (prior == null || head.version >= prior.version) out[entity] = head.asFloor()
}
}
return out
}
fun fold(
editions: Collection<ControlEdition>,
ownerPubKey: String,
floors: Map<String, EntityFloor> = emptyMap(),
): ConcordCommunityState {
val heads = EditionFold.fold(editions).values
// Everything below folds a *derived* view of the same editions (the resolver's
// authority chains, the per-kind gated folds), so the anti-rollback floor is applied
// once, up front, on the shared pool: a rolled-back edition is never seen by any of
// them, and the head we already folded is re-seated so the entity keeps its state.
@Suppress("NAME_SHADOWING")
val editions = EditionFold.admissible(editions, floors)
val heads = EditionFold.fold(editions, floors).values
// Resolve authority from the FULL edition set (not the structural heads): the resolver
// folds each role/grant chain through authorized editions only, so a rogue higher-version
// edition can't supersede a legit one before authority is even judged.
@@ -84,7 +144,7 @@ class ConcordCommunityState(
// authorized editions, then take the highest-version head (guarding against strays).
val metadata =
EditionFold
.fold(editorsWith(ControlEntityKind.METADATA, ConcordPermissions.MANAGE_METADATA))
.fold(editorsWith(ControlEntityKind.METADATA, ConcordPermissions.MANAGE_METADATA), floors)
.values
.maxByOrNull { it.version }
?.let { ConcordJson.decodeOrNull<MetadataEntity>(it.content) }
@@ -92,7 +152,7 @@ class ConcordCommunityState(
// Channels are gated by MANAGE_CHANNELS. Fold each channel entity from its authorized
// editions only, dropping the tombstoned ones.
val channels = LinkedHashMap<String, ConcordChannel>()
for (head in EditionFold.fold(editorsWith(ControlEntityKind.CHANNEL, ConcordPermissions.MANAGE_CHANNELS)).values) {
for (head in EditionFold.fold(editorsWith(ControlEntityKind.CHANNEL, ConcordPermissions.MANAGE_CHANNELS), floors).values) {
val def = ConcordJson.decodeOrNull<ChannelEntity>(head.content) ?: continue
if (def.deleted) continue
channels[head.entityIdHex] = ConcordChannel(head.entityIdHex, def)
@@ -21,6 +21,43 @@
package com.vitorpamplona.quartz.concord.cord04Roles
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.Log
/**
* The anti-rollback floor for one Control Plane entity: the [version] and
* [hashHex] of an edition this client already folded to, plus (when we still hold
* it) that [known] edition itself.
*
* A CORD-06 Refounding compacts the Control Plane by re-wrapping **one edition per
* entity** at the new epoch, and the *rotator* picks which one. Nothing in a
* signature stops it from re-wrapping version 1 of a chain that had reached
* version 2 — restoring a revoked role, clearing a banlist, reverting metadata —
* because every edition it serves is genuine. This is rollback by omission, and
* the only defense is memory: a client that already folded to v2 must refuse to
* come back down. [EntityFloor] is that memory, and [EditionFold.foldEntity] /
* [EditionFold.admissible] are where it is enforced.
*
* [known] is what "keeps its existing state" means concretely: when the offered
* chain cannot be connected to the floor, we fall back to the edition we last
* folded rather than letting the entity vanish (an entity vanishing from the fold
* is itself a rollback — a dropped banlist is an unban).
*/
class EntityFloor(
val version: Long,
val hashHex: String,
val known: ControlEdition? = null,
)
/** This edition as an anti-rollback floor for its entity. */
fun ControlEdition.asFloor(): EntityFloor = EntityFloor(version, hashHex, this)
/**
* Called when an entity's offered chain cannot be connected to the floor this
* client already holds — i.e. someone tried to move the entity backwards. The
* arguments are the entity id, the floor version we refuse to drop below, and the
* highest version offered.
*/
typealias GapReporter = (entityIdHex: String, floorVersion: Long, offeredVersion: Long) -> Unit
/**
* Folds Control Plane editions into the current head of each entity (CORD-04
@@ -29,7 +66,14 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey
* Rules enforced here:
* - **Genesis anchoring** — a chain starts at the lowest-version edition with no
* `ep` (prev hash).
* - **Refounding fallback (fresh joiner)** — when no genesis is present, anchor
* - **Anti-rollback floor** — when the caller supplies an [EntityFloor] (an
* entity head it already folded), the walk is *anchored at that floor*: it must
* find the exact edition (version + hash) it already knew. If it cannot, the
* offered chain is a **gap** and nothing above the floor is adopted — the entity
* keeps [EntityFloor.known] instead. This is what stops a Refounding rotator
* from serving v1 of a chain that had reached v2 (see [EntityFloor]).
* - **Refounding fallback (fresh joiner)** — when no genesis is present *and no
* floor is held*, anchor
* at the lowest-version edition available and accept it as the baseline. After
* a Refounding (CORD-06 §3) the compacted head still carries the `ep` it had
* before compaction, citing an edition in the *prior* epoch that a fresh joiner
@@ -51,36 +95,86 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey
* this structural fold; this class is purely the chain walk.
*/
object EditionFold {
/** Groups mixed [editions] by entity id and folds each to its head. */
fun fold(editions: Collection<ControlEdition>): Map<String, ControlEdition> {
private const val TAG = "ConcordEditionFold"
/**
* The default [GapReporter]: a rollback refusal is security-relevant (a rotator
* tried to revert an entity), so it is warned, never swallowed.
*/
val LOG_GAP: GapReporter = { entityIdHex, floorVersion, offeredVersion ->
Log.w(TAG) {
"Control-plane rollback refused for entity $entityIdHex: already folded v$floorVersion, offered chain tops out at v$offeredVersion and does not connect to it"
}
}
/**
* Groups mixed [editions] by entity id and folds each to its head, honoring the
* per-entity anti-rollback [floors] (keyed by [ControlEdition.entityIdHex]).
*
* Only entities actually present in [editions] are folded — re-seating an entity
* that was omitted entirely (the cheapest rollback of all) is [admissible]'s job,
* because it runs once on the whole pool, while this is also called on per-kind
* subsets that must not have other kinds' heads injected into them.
*/
fun fold(
editions: Collection<ControlEdition>,
floors: Map<String, EntityFloor> = emptyMap(),
onGap: GapReporter = LOG_GAP,
): Map<String, ControlEdition> {
val byEntity = editions.groupBy { it.entityIdHex }
val out = HashMap<String, ControlEdition>(byEntity.size)
for ((entity, list) in byEntity) {
foldEntity(list)?.let { out[entity] = it }
foldEntity(list, floors[entity], onGap)?.let { out[entity] = it }
}
return out
}
/** Folds the editions of a single entity into its current head, or null. */
fun foldEntity(editions: List<ControlEdition>): ControlEdition? {
if (editions.isEmpty()) return null
/**
* Folds the editions of a single entity into its current head, or null.
*
* With no [floor] this is the fresh-joiner fold: genesis-anchored, falling back
* to the lowest-version edition present (the compaction bootstrap). With a
* [floor] the walk is anchored at the exact edition already folded; if that
* edition is not among [editions] the chain is **gapped** and nothing above the
* floor is adopted — [EntityFloor.known] is kept instead (or null when we no
* longer hold it). A head is therefore never below the floor version.
*/
fun foldEntity(
editions: List<ControlEdition>,
floor: EntityFloor? = null,
onGap: GapReporter = LOG_GAP,
): ControlEdition? {
if (editions.isEmpty()) return floor?.known
// Index editions by version, keeping the tie-break winner where several
// share a version (lower rumor id wins).
val byVersion = HashMap<Long, MutableList<ControlEdition>>()
for (e in editions) byVersion.getOrPut(e.version) { ArrayList() }.add(e)
// Anchor at the genesis (lowest version with no prev hash), preferring the
// tie-break winner. When no genesis is present — the compacted head of a
// Refounded community carries a prev citing the prior epoch — a fresh joiner
// anchors at the lowest-version edition it does hold and accepts it as the
// baseline (CORD-04 §1 / CORD-06 §3). `editions` is non-empty here.
var head =
editions
.filter { it.prevHash == null }
.minWithOrNull(compareBy({ it.version }, { it.rumorId }))
?: editions.minWithOrNull(compareBy({ it.version }, { it.rumorId }))
?: return null
if (floor != null) {
// Anchored at what we already folded: the offered set MUST contain that exact
// edition (same version AND same hash — a same-version sibling is a fork, not
// our chain). Failing that, refuse to move at all rather than accept an
// unverifiable jump; walking up from the floor also makes a head below the
// floor version structurally impossible.
editions.firstOrNull { it.version == floor.version && it.hashHex == floor.hashHex }
?: run {
onGap(editions[0].entityIdHex, floor.version, editions.maxOf { it.version })
return floor.known
}
} else {
// Anchor at the genesis (lowest version with no prev hash), preferring the
// tie-break winner. When no genesis is present — the compacted head of a
// Refounded community carries a prev citing the prior epoch — a fresh joiner
// anchors at the lowest-version edition it does hold and accepts it as the
// baseline (CORD-04 §1 / CORD-06 §3). `editions` is non-empty here.
editions
.filter { it.prevHash == null }
.minWithOrNull(compareBy({ it.version }, { it.rumorId }))
?: editions.minWithOrNull(compareBy({ it.version }, { it.rumorId }))
?: return null
}
// Walk the chain upward while the next version chains from the current head.
while (true) {
@@ -93,4 +187,49 @@ object EditionFold {
}
return head
}
/**
* The subset of [editions] a client holding [floors] may consider at all — the
* pre-filter for the layers that fold *derived* views of the same editions
* (authority resolution, per-kind gated folds) and therefore cannot each carry
* the floor themselves.
*
* Per entity: if the offered set contains the floor edition, the chain connects
* and everything is admissible. If it does not, the entity is **gapped** and
* every offered edition at or above the floor version is dropped, with
* [EntityFloor.known] substituted so the entity keeps the state we last folded.
* Entities with no floor pass through untouched (a fresh joiner must not be
* penalized for having no history).
*/
fun admissible(
editions: Collection<ControlEdition>,
floors: Map<String, EntityFloor>,
onGap: GapReporter = LOG_GAP,
): List<ControlEdition> {
if (floors.isEmpty()) return editions.toList()
val out = ArrayList<ControlEdition>(editions.size + floors.size)
val seen = HashSet<String>(floors.size)
for ((entity, list) in editions.groupBy { it.entityIdHex }) {
seen.add(entity)
val floor = floors[entity]
if (floor == null) {
out.addAll(list)
continue
}
if (list.any { it.version == floor.version && it.hashHex == floor.hashHex }) {
out.addAll(list)
continue
}
onGap(entity, floor.version, list.maxOf { it.version })
// Below the floor is history we already absorbed; at or above it is the jump we
// refuse. Re-seat the known head so the entity's state is kept, not cleared.
list.filterTo(out) { it.version < floor.version }
floor.known?.let { out.add(it) }
}
for ((entity, floor) in floors) {
if (entity !in seen) floor.known?.let { out.add(it) }
}
return out
}
}
@@ -0,0 +1,174 @@
/*
* 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.quartz.concord.cord04Roles
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Anti-rollback: a CORD-06 Refounding re-wraps ONE edition per entity at the new epoch and the
* *rotator* chooses which one, so it can serve v1 of a chain that had reached v2 — restoring a
* revoked role, clearing a banlist, reverting metadata — with every signature genuine. Rollback
* by omission, not forgery. A client that already folded to v2 must refuse to come back down.
*/
class EditionFoldFloorTest {
private val author = KeyPair().pubKey.toHexKey()
private val eid = ByteArray(32) { 0xAB.toByte() }
private val otherEid = ByteArray(32) { 0xCD.toByte() }
private fun edition(
version: Long,
prevHash: ByteArray?,
content: String,
entityId: ByteArray = eid,
rumorId: String = "id-v$version",
) = ControlEdition(
entityKind = ControlEntityKind.GRANT,
entityId = entityId,
version = version,
prevHash = prevHash,
authorityCitation = null,
content = content,
author = author,
rumorId = rumorId,
createdAt = 1_700_000_000L + version,
)
// A grant chain: v0 grants the role, v1 edits it, v2 revokes it. Rolling back to v1 restores
// a role the community already revoked.
private val v0 = edition(0, null, """{"member":"a","role_ids":["mod"]}""")
private val v1 = edition(1, v0.hash, """{"member":"a","role_ids":["admin"]}""")
private val v2 = edition(2, v1.hash, """{"member":"a","role_ids":[]}""")
private val v3 = edition(3, v2.hash, """{"member":"a","role_ids":["mod"]}""")
private val floorAtV2 = v2.asFloor()
// ---- foldEntity ----------------------------------------------------------
/** The attack: the rotator serves only v1, whose prev dangles (v0 was not re-wrapped). */
@Test
fun refusesRollbackToAnOmittedLowerVersion() {
val gaps = mutableListOf<Triple<String, Long, Long>>()
val head = EditionFold.foldEntity(listOf(v1), floorAtV2) { e, f, o -> gaps += Triple(e, f, o) }
assertEquals(v2.rumorId, head?.rumorId, "the revoked-at-v2 head must survive the rollback")
assertEquals(listOf(Triple(v2.entityIdHex, 2L, 1L)), gaps, "the refusal must be reported")
}
/** Even an intact prefix (v0→v1) is a downgrade when we already folded v2. */
@Test
fun refusesRollbackEvenWhenTheOfferedPrefixIsIntact() {
val head = EditionFold.foldEntity(listOf(v0, v1), floorAtV2) { _, _, _ -> }
assertEquals(v2.rumorId, head?.rumorId)
}
/** A same-version forgery is a fork, not our chain: the floor matches on hash, not version. */
@Test
fun refusesSiblingAtTheFloorVersion() {
val forgedV2 = edition(2, v1.hash, """{"member":"a","role_ids":["owner-ish"]}""", rumorId = "id-forged")
val head = EditionFold.foldEntity(listOf(v1, forgedV2), floorAtV2) { _, _, _ -> }
assertEquals(v2.rumorId, head?.rumorId)
assertEquals(v2.content, head?.content)
}
/** An honest compaction re-wraps the very head we hold: the walk connects, so it is adopted. */
@Test
fun acceptsHonestCompactionAtTheFloor() {
val gaps = mutableListOf<String>()
val head = EditionFold.foldEntity(listOf(v2), floorAtV2) { e, _, _ -> gaps += e }
assertEquals(v2.rumorId, head?.rumorId)
assertTrue(gaps.isEmpty(), "an honest compaction must not report a gap")
}
/** And it advances past the floor when the new epoch chains forward from it. */
@Test
fun advancesAboveTheFloorWhenTheChainConnects() {
val head = EditionFold.foldEntity(listOf(v3, v2), floorAtV2) { _, _, _ -> }
assertEquals(v3.rumorId, head?.rumorId)
}
/** A floor whose known edition we no longer hold still refuses to move down — it adopts nothing. */
@Test
fun refusesRollbackWithNoKnownEditionToFallBackOn() {
val hashOnlyFloor = EntityFloor(v2.version, v2.hashHex, known = null)
assertNull(EditionFold.foldEntity(listOf(v1), hashOnlyFloor) { _, _, _ -> })
}
/**
* A fresh joiner holds no floor and MUST still accept a dangling compacted head as its
* baseline (CORD-04 §1 / CORD-06 §3) — it legitimately has no history to fail closed on.
* Regression guard for `ControlEditionTest.foldWithoutGenesisAcceptsCompactedHead`.
*/
@Test
fun freshJoinerWithoutAFloorStillAcceptsADanglingCompactedHead() {
assertEquals(v1.rumorId, EditionFold.foldEntity(listOf(v1), floor = null)?.rumorId)
}
// ---- fold (per-entity map) -----------------------------------------------
@Test
fun foldAppliesTheFloorPerEntity() {
val otherV0 = edition(0, null, """{"member":"b","role_ids":["mod"]}""", entityId = otherEid, rumorId = "other-v0")
val heads = EditionFold.fold(listOf(v1, otherV0), mapOf(v2.entityIdHex to floorAtV2)) { _, _, _ -> }
assertEquals(v2.rumorId, heads[v2.entityIdHex]?.rumorId, "floored entity refuses the rollback")
assertEquals(otherV0.rumorId, heads[otherV0.entityIdHex]?.rumorId, "un-floored entity folds normally")
}
// ---- admissible (the pre-filter the derived folds share) ------------------
/** A gapped entity loses every edition at or above the floor, and keeps the head we knew. */
@Test
fun admissibleDropsTheRolledBackChainAndReSeatsTheKnownHead() {
val forgedV2 = edition(2, v1.hash, """{"member":"a","role_ids":["admin"]}""", rumorId = "id-forged")
val admissible = EditionFold.admissible(listOf(v0, v1, forgedV2), mapOf(v2.entityIdHex to floorAtV2)) { _, _, _ -> }
assertContentEquals(listOf(v0.rumorId, v1.rumorId, v2.rumorId), admissible.map { it.rumorId })
}
/** When the chain connects, nothing is filtered — the new epoch may legitimately advance. */
@Test
fun admissiblePassesEverythingThroughWhenTheChainConnects() {
val admissible = EditionFold.admissible(listOf(v2, v3), mapOf(v2.entityIdHex to floorAtV2)) { _, _, _ -> }
assertContentEquals(listOf(v2.rumorId, v3.rumorId), admissible.map { it.rumorId })
}
/** Omitting an entity outright is the cheapest rollback of all (a dropped banlist is an unban). */
@Test
fun admissibleKeepsAnEntityThatWasOmittedEntirely() {
val otherV0 = edition(0, null, """{"member":"b","role_ids":["mod"]}""", entityId = otherEid, rumorId = "other-v0")
val admissible = EditionFold.admissible(listOf(otherV0), mapOf(v2.entityIdHex to floorAtV2)) { _, _, _ -> }
assertContentEquals(listOf(otherV0.rumorId, v2.rumorId), admissible.map { it.rumorId })
}
/** No floors at all → the fresh-joiner path, untouched. */
@Test
fun admissibleIsAPassThroughWithoutFloors() {
val admissible = EditionFold.admissible(listOf(v1), emptyMap())
assertContentEquals(listOf(v1.rumorId), admissible.map { it.rumorId })
}
}