mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 08:27:04 +00:00
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:
co-authored by
Claude Opus 4.8
parent
6f63b33119
commit
8060dc0bb1
+18
-3
@@ -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.
|
||||
|
||||
+8
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+105
@@ -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()))
|
||||
}
|
||||
}
|
||||
+71
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user