fix(concord): fold refounded community control plane for fresh joiners

After a Refounding (CORD-06 §3), compaction re-wraps each entity's head
verbatim, so a head edited past genesis still carries an `ep`/prev citing
an edition in the prior epoch that a fresh joiner never fetches.
EditionFold.foldEntity required a genesis edition (prevHash == null) and
returned null otherwise, so a fresh login folded the whole Control Plane to
nothing: no community icon, no name, no edited channels (while Armada,
which implements the CORD-04 §1 fresh-joiner rule, showed them all).

Anchor at the lowest-version edition when no genesis is present and walk up
from there. Safe: Amethyst always re-folds the whole buffer from scratch
(no persistent floor), so it is structurally always a fresh joiner;
authority is validated on top by AuthorityResolver, so an unrooted forgery
is still dropped; and a genuine mid-chain gap still stops at the intact
prefix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-07-14 17:12:49 -04:00
co-authored by Claude Opus 4.8
parent 6f63b33119
commit 8060dc0bb1
4 changed files with 202 additions and 5 deletions
@@ -28,8 +28,18 @@ 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). Without a genesis, the entity has no accepted head (we
* hold rather than trust an unrooted later edition).
* `ep` (prev hash).
* - **Refounding fallback (fresh joiner)** — when no genesis is present, 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
* never fetches — so a dangling `prev` is the norm, not corruption, and CORD-04
* §1 ("Folding across a Refounding") requires the joiner to take that head as
* its baseline. The signature + owner-rooted authority check (applied by
* [com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver] on top of this
* structural fold) is the whole test, so an unrooted forgery is still dropped
* there. Amethyst always re-folds the whole buffer from scratch, so it is
* structurally always a fresh joiner; it holds no prior chain to fail closed on.
* - **Intact chain / no downgrades** — the head advances to `version + 1` only
* when that edition's `ep` cites the current head's [ControlEdition.hash].
* Lower or non-chaining versions are ignored.
@@ -60,11 +70,16 @@ object EditionFold {
val byVersion = HashMap<Long, MutableList<ControlEdition>>()
for (e in editions) byVersion.getOrPut(e.version) { ArrayList() }.add(e)
// Genesis: lowest version with no prev hash. Prefer the tie-break winner.
// 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
// Walk the chain upward while the next version chains from the current head.
@@ -148,9 +148,15 @@ class ControlEditionTest {
assertEquals("aaa", EditionFold.foldEntity(listOf(b, a))?.rumorId)
}
/**
* A lone edition with a dangling `prev` and no genesis is the compacted head of a Refounded
* community (CORD-06 §3): a fresh joiner never holds the prior epoch it chains onto, so the
* head is accepted as the baseline rather than dropped (CORD-04 §1). Dropping it was the bug
* that hid a refounded community's icon, name, and edited channels. See [EditionFoldTest].
*/
@Test
fun foldWithoutGenesisReturnsNull() {
fun foldWithoutGenesisAcceptsCompactedHead() {
val v1 = edition(1, ByteArray(32) { 0x05 }, """{"name":"x"}""", "id1")
assertNull(EditionFold.foldEntity(listOf(v1)))
assertEquals("id1", EditionFold.foldEntity(listOf(v1))?.rumorId)
}
}
@@ -0,0 +1,105 @@
/*
* 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.assertEquals
import kotlin.test.assertNull
class EditionFoldTest {
private val author = KeyPair().pubKey.toHexKey()
private val eid = ByteArray(32) { 0xAB.toByte() }
private fun edition(
version: Long,
prevHash: ByteArray?,
content: String,
rumorId: String = "id-v$version",
) = ControlEdition(
entityKind = ControlEntityKind.CHANNEL,
entityId = eid,
version = version,
prevHash = prevHash,
authorityCitation = null,
content = content,
author = author,
rumorId = rumorId,
createdAt = 1_700_000_000L + version,
)
/** A normal chain (v0 genesis → v1 → v2) folds to the highest intact version. */
@Test
fun foldsIntactChainToHead() {
val v0 = edition(0, null, "genesis")
val v1 = edition(1, v0.hash, "one")
val v2 = edition(2, v1.hash, "two")
val head = EditionFold.foldEntity(listOf(v2, v0, v1))
assertEquals(2, head?.version)
assertEquals("two", head?.content)
}
/**
* The Refounding case (CORD-06 §3): a fresh joiner holds only the compacted head, whose
* `prev` cites the prior epoch it never fetched. With no genesis present, the head is
* accepted as the baseline rather than dropped — the bug that hid a refounded community's
* icon, name, and edited channels.
*/
@Test
fun acceptsDanglingCompactedHeadWhenNoGenesis() {
val danglingHead = edition(5, ByteArray(32) { 0x99.toByte() }, "compacted-head")
val head = EditionFold.foldEntity(listOf(danglingHead))
assertEquals(5, head?.version)
assertEquals("compacted-head", head?.content)
}
/** A dangling head plus post-refounding edits chains forward from the accepted baseline. */
@Test
fun advancesFromDanglingHeadAsNewEditionsArrive() {
val danglingHead = edition(5, ByteArray(32) { 0x99.toByte() }, "compacted-head")
val v6 = edition(6, danglingHead.hash, "post-refound edit")
val head = EditionFold.foldEntity(listOf(v6, danglingHead))
assertEquals(6, head?.version)
assertEquals("post-refound edit", head?.content)
}
/** A genuine mid-chain gap still fails closed at the intact prefix — no silent jump past the hole. */
@Test
fun stopsAtGapWhenGenesisPresent() {
val v0 = edition(0, null, "genesis")
// v1 is missing; v2 cites a hash we don't hold, so it can't chain onto v0.
val v2 = edition(2, ByteArray(32) { 0x77.toByte() }, "orphan")
val head = EditionFold.foldEntity(listOf(v2, v0))
assertEquals(0, head?.version)
assertEquals("genesis", head?.content)
}
/** No editions at all → no head. */
@Test
fun emptyFoldsToNull() {
assertNull(EditionFold.foldEntity(emptyList()))
}
}
@@ -22,6 +22,13 @@ package com.vitorpamplona.quartz.concord.cord06Rekey
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEditionBuilder
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
@@ -126,6 +133,70 @@ class ConcordRefoundingTest {
}
}
@Test
fun freshJoinerSeesEntitiesEditedAfterGenesisThenRefounded() =
runTest {
// A real, long-lived community edits its metadata (adds an icon) and renames
// #general AFTER genesis, THEN gets refounded. Those edits produce version-1
// editions whose `ep` chains onto the genesis edition. Compaction keeps only
// each entity's head — so the re-wrapped heads still carry a `prev` pointing at
// the (now absent) prior-epoch edition. A fresh joiner fetching only the
// compacted heads must still see them (CORD-04 §1 "Folding across a Refounding",
// CORD-06 §3): the signature + current-authority check is the whole test.
val community = ConcordCommunityFactory.create(owner, "NosFabrica", now)
val communityId = community.communityId
val control = community.controlPlane
val genesisMeta = community.genesisEditions.first { it.entityKind == ControlEntityKind.METADATA }
val genesisChannel = community.genesisEditions.first { it.entityKind == ControlEntityKind.CHANNEL }
val icon = ImagePointer(url = "https://media/icon.enc", key = "1a".repeat(32), nonce = "2b".repeat(16), hash = "3c".repeat(32))
// v1 metadata: add the icon, chained onto genesis.
val metaV1Json = ConcordJson.instance.encodeToString(MetadataEntity.serializer(), MetadataEntity(name = "NosFabrica", icon = icon))
val metaV1Rumor = ControlEditionBuilder.rumor(owner.pubKey, ControlEntityKind.METADATA, communityId, 1, genesisMeta.hash, metaV1Json, now + 1)
val metaV1Wrap = ConcordStreamEnvelope.wrap(metaV1Rumor, control, owner, encrypted = false, createdAt = now + 1)
// v1 channel: rename #general, chained onto genesis.
val chanV1Json = ConcordJson.instance.encodeToString(ChannelEntity.serializer(), ChannelEntity(name = "lobby", private = false))
val chanV1Rumor = ControlEditionBuilder.rumor(owner.pubKey, ControlEntityKind.CHANNEL, community.generalChannelId, 1, genesisChannel.hash, chanV1Json, now + 1)
val chanV1Wrap = ConcordStreamEnvelope.wrap(chanV1Rumor, control, owner, encrypted = false, createdAt = now + 1)
val priorWraps = community.genesisWraps + metaV1Wrap + chanV1Wrap
val build =
ConcordRefounding.build(
rotatorSigner = owner,
communityId = communityId,
priorRoot = community.communityRoot,
newRoot = newRoot,
rootEpoch = community.rootEpoch,
priorControlWraps = priorWraps,
priorControlKey = control,
recipientsXOnly = listOf(alice.pubKey),
createdAt = now,
)
val newControl = ConcordKeyDerivation.controlPlaneKey(newRoot, communityId, build.newEpoch)
val editions =
build.controlWraps.mapNotNull { wrap ->
ConcordStreamEnvelope.openOrNull(wrap, newControl)?.let { ControlEdition.fromRumor(it.rumor) }
}
val folded = ConcordCommunityState.fold(editions, owner.pubKey)
// A fresh joiner MUST see the compacted heads — name, icon, and the renamed channel.
assertEquals("NosFabrica", folded.metadata?.name, "fresh joiner lost the community name after refounding")
assertEquals(icon, folded.metadata?.icon, "fresh joiner lost the community icon after refounding")
assertEquals(
"lobby",
folded.channels.values
.firstOrNull()
?.definition
?.name,
"fresh joiner lost the (edited) channel after refounding",
)
}
@Test
fun wrongPriorRootFailsContinuity() =
runTest {