mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
co-authored by
Claude Opus 4.8
parent
30f4638954
commit
fac1bf5b5d
+24
-4
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+76
-1
@@ -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()
|
||||
|
||||
+3
@@ -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
|
||||
|
||||
+233
@@ -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")
|
||||
}
|
||||
}
|
||||
+63
-3
@@ -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)
|
||||
|
||||
+156
-17
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+174
@@ -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 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user