diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt index 32869407e1..7670ff69c6 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt @@ -40,6 +40,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update +import kotlin.concurrent.Volatile /** * A validated inner chat rumor emitted by a session: its parent [communityId] and @@ -97,10 +98,23 @@ enum class ConcordIngestOutcome { * [ConcordActions]/[ConcordPlaneRegistry] helpers. */ class ConcordCommunitySession( - val entry: ConcordCommunityListEntry, + entry: ConcordCommunityListEntry, val myPubKey: HexKey, private val onRumor: ConcordRumorSink = { _, _, _, _ -> }, ) { + /** + * The joined-list entry this session projects. Replaced in place — only ever by + * [adoptControlMaterial], and only within one epoch — because the Control Plane + * write key can arrive long after the session was built (CORD-04 §3). A change + * that moves the *planes* (a Refounding) rebuilds the session instead. + * + * Volatile because the ingest path, the UI and the adopting drain are different + * threads: the write happens under [lock], but readers take it unsynchronized. + */ + @Volatile + var entry: ConcordCommunityListEntry = entry + private set + private val root = entry.root.hexToByteArray() private val communityIdBytes = entry.id.hexToByteArray() @@ -109,7 +123,8 @@ class ConcordCommunitySession( * carries a `control_pk` (plus the write key when this account is staff and * holds the `control_root`), legacy single-key otherwise. */ - private val controlKeys: ControlPlaneKeys = ConcordActions.controlPlaneKeysFor(entry) + @Volatile + private var controlKeys: ControlPlaneKeys = ConcordActions.controlPlaneKeysFor(entry) /** The Guestbook Plane at this epoch — where member join/leave motions ride (CORD-02 §5). */ private val guestbookKey: GroupKey = ConcordActions.guestbookPlane(root, communityIdBytes, entry.rootEpoch) @@ -329,7 +344,38 @@ class ConcordCommunitySession( * editions. [ControlPlaneKeys.canWrite] is false for a regular member on a split * epoch (CORD-02 §2) — the caller must not attempt to publish an edition then. */ - fun controlPlaneKeys(): ControlPlaneKeys = controlKeys + fun controlPlaneKeys(): ControlPlaneKeys = lock.withLock { controlKeys } + + /** + * Adopt Control Plane key material that arrived *after* this session was built, at + * the same epoch: the `control_root` a staff-making Grant delivers (CORD-04 §3), or + * a `control_pk` filled in by a same-epoch Community List merge (CORD-02 §8). + * + * Done in place rather than by rebuilding the session, because a rebuild would drop + * the buffered Control Plane wraps and leave the community folded empty until every + * wrap happened to be re-delivered. Nothing about the *plane* moves here: adoption is + * gated on the secret deriving to exactly the `control_pk` already held (CORD-02 §5), + * so the address, the read key, the buffered wraps and the subscription set are all + * invariant — only [ControlPlaneKeys.signer] appears, flipping + * [ControlPlaneKeys.canWrite] and adding the stream key to [streamKeys]. + * + * Fails closed and returns false when [newEntry] is not the same community at the + * same root and epoch, or when the material it carries would move the plane's + * address — a caller must rebuild the session for that, never mutate it. Returns + * false too when nothing changed, so the caller can skip a needless revision bump. + */ + fun adoptControlMaterial(newEntry: ConcordCommunityListEntry): Boolean = + lock.withLock { + if (newEntry.id != entry.id || newEntry.root != entry.root || newEntry.rootEpoch != entry.rootEpoch) return@withLock false + if (newEntry.controlPk == entry.controlPk && newEntry.controlRoot == entry.controlRoot) return@withLock false + val newKeys = ConcordActions.controlPlaneKeysFor(newEntry) + // The plane is where the buffered wraps already are. If the new material points + // somewhere else, this is not an adoption — refuse and let the caller rebuild. + if (newKeys.address != controlKeys.address) return@withLock false + entry = newEntry + controlKeys = newKeys + true + } /** This account's standing, from the current fold. */ fun membership(): ConcordMembership { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt index cb366ba6fa..09be414805 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt @@ -78,6 +78,13 @@ class ConcordSessionRegistry( if (existing == null || existing.entry.root != entry.root || existing.entry.rootEpoch != entry.rootEpoch) { sessions[id] = ConcordCommunitySession(entry, myPubKey, onRumor) created += id + } else { + // Same epoch, but the Control Plane write key may have just arrived — a + // staff-making Grant delivers it inside the fold itself (CORD-04 §3), long + // after this session was built. Adopt it in place: rebuilding would drop the + // buffered wraps and fold the community empty, and the plane's address is + // invariant under adoption anyway (CORD-02 §5). + existing.adoptControlMaterial(entry) } } created diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistryTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistryTest.kt index a8bedadc73..81519fd100 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistryTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistryTest.kt @@ -30,8 +30,10 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull +import kotlin.test.assertSame import kotlin.test.assertTrue class ConcordSessionRegistryTest { @@ -40,14 +42,16 @@ class ConcordSessionRegistryTest { private fun entryFor( community: NewConcordCommunity, name: String, + controlRoot: String? = community.controlRoot.toHexKey(), + rootEpoch: Long = community.rootEpoch, ) = ConcordCommunityListEntry( id = community.communityIdHex, owner = community.ownerPubKey, ownerSalt = community.ownerSalt.toHexKey(), root = community.communityRoot.toHexKey(), - rootEpoch = community.rootEpoch, + rootEpoch = rootEpoch, controlPk = community.controlPkHex, - controlRoot = community.controlRoot.toHexKey(), + controlRoot = controlRoot, relays = listOf("wss://r.example"), name = name, ) @@ -104,4 +108,59 @@ class ConcordSessionRegistryTest { val gamma = ConcordCommunityFactory.create(owner, "Gamma", createdAt = 1L, relays = listOf("wss://r.example")) assertEquals(ConcordIngestOutcome.NOT_MINE, registry.ingest(gamma.genesisWraps.first())) } + + /** + * A promotion to staff delivers the `control_root` inside the fold itself (CORD-04 §3), + * so it lands on an entry whose session was built as a read-only member long before. The + * session must pick the key up **without** being rebuilt: a rebuild would drop the + * buffered Control Plane wraps and fold the community empty, which is exactly what the + * user would see instead of their new moderation powers. + */ + @Test + fun adoptsAControlRootDeliveredMidSessionWithoutLosingTheFold() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Alpha", createdAt = 1L, relays = listOf("wss://r.example")) + val registry = ConcordSessionRegistry() + + // Joined as a plain member: the address is held, the write secret is not. + val asMember = entryFor(community, "Alpha", controlRoot = null) + registry.sync(listOf(asMember), owner.pubKey) + val session = registry.sessionFor(community.communityIdHex)!! + community.genesisWraps.forEach { registry.ingest(it) } + + assertEquals( + "Alpha", + session.state.value + ?.metadata + ?.name, + ) + assertFalse(session.controlPlaneKeys().canWrite, "a member holds no control_root") + + // The staff-making Grant lands and the drain writes the secret onto the entry. + val asStaff = entryFor(community, "Alpha") + val createdOnAdopt = registry.sync(listOf(asStaff), owner.pubKey) + + // Adopted in place: same session object, no rebuild. + assertTrue(createdOnAdopt.isEmpty(), "adopting a control_root must not rebuild the session") + assertSame(session, registry.sessionFor(community.communityIdHex)) + + // The write key is live... + assertTrue(session.controlPlaneKeys().canWrite, "the delivered control_root must flip canWrite") + assertEquals(community.controlRoot.toHexKey(), session.entry.controlRoot, "the entry carries it onward for the next Grant") + assertTrue(session.streamKeys().any { it.publicKeyHex == community.controlPkHex }, "staff now AUTHs as the plane") + + // ...and the address and the fold are untouched — the bug this guards. + assertEquals(community.controlPlane.address, session.controlPlaneAddress) + assertEquals( + "Alpha", + session.state.value + ?.metadata + ?.name, + "adoption must not discard the buffered wraps", + ) + + // A real rotation still rebuilds rather than adopting in place. + val nextEpoch = entryFor(community, "Alpha", rootEpoch = community.rootEpoch + 1) + assertEquals(setOf(community.communityIdHex), registry.sync(listOf(nextEpoch), owner.pubKey)) + } }