fix(concord): adopt a mid-session control_root without rebuilding the session

A staff-making Grant 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. `ConcordSessionRegistry.sync` only rebuilt a
session when `root`/`rootEpoch` changed, and `ConcordCommunitySession`
derived `controlKeys` once at construction — adoption changes neither, so
the live session kept `signer = null` and `canWrite == false` for the rest
of the process.

The promoted staffer saw their new role badge appear (that half reads the
folded `state` flow) while every write affordance stayed hidden and
`controlKeysForWrite` refused, until the app was restarted.

Rebuilding the session on the change is not the fix: the new session starts
with no buffered Control Plane wraps, so the community folds to "No channels
yet" until every wrap happens to be re-delivered. Instead refresh the key
material in place. Nothing about the plane moves — adoption is gated on the
secret deriving to exactly the `control_pk` already held (CORD-02 §5) — so
the address, read key, buffered wraps and subscription set are all
invariant, and only the signer appears. `adoptControlMaterial` fails closed
on a different community/root/epoch or an address change, leaving those to a
rebuild.

Verified on device (SM-T220, Android 14) against a loopback geode relay: an
account promoted to staff while sitting on the community screen gains the
edit/create affordances with no restart, keeps its folded channel list, and
its next Control edition lands on the wire signed by `control_pk`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-08-08 12:01:55 -04:00
co-authored by Claude Opus 5
parent d7226b7021
commit c84de87361
3 changed files with 117 additions and 5 deletions
@@ -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 {
@@ -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
@@ -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))
}
}