mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 08:27:04 +00:00
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:
co-authored by
Claude Opus 5
parent
d7226b7021
commit
c84de87361
+49
-3
@@ -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 {
|
||||
|
||||
+7
@@ -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
|
||||
|
||||
+61
-2
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user