fix: resolve a Concord invite coordinate newest-wins on the live branch too

`ConcordInviteBundle.classify` resolved a revocation tombstone by `createdAt`
(newest wins, per CORD-05 §2 replaceable semantics) but picked the live bundle
with `firstNotNullOfOrNull` — i.e. whichever copy happened to decrypt first in
relay arrival order. `wraps` comes straight off `fetchAll` at every call site
(`joinConcordViaInvite`, `amy concord join`, `refreshConcordInviteLinks`), and
`fetchAll` gives no ordering guarantee.

A Refounding re-mints every live link at its OWN coordinate carrying the new
epoch's root. Until now, a relay still serving the pre-Refounding bundle could
hand a joiner the root of the epoch the community had just left: they would
join, post into planes nobody reads, and see no error explaining why. The file's
own KDoc and the CLI's redeem comment both already claimed newest-wins here.

Sorting newest-first also decrypts fewer bundles in the common case, since the
current edition is now tried first.

Regression test covers both fetch orders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WcaCNiqN8T9izve4AXcake
This commit is contained in:
Claude
2026-09-03 18:55:32 +00:00
parent 401f6246ab
commit 5f88e0e971
2 changed files with 31 additions and 2 deletions
@@ -144,7 +144,7 @@ object ConcordInviteBundle {
* [InviteBundleStatus] under CORD-05 §2 replaceable semantics. The newest event
* wins: a `vsk=9` revocation tombstone marks the link [InviteBundleStatus.Revoked]
* even when an older, still-openable bundle is also present (so a stale relay copy
* can't resurrect a retired link). Otherwise the first `vsk=6` bundle that opens +
* can't resurrect a retired link). Otherwise the **newest** `vsk=6` bundle that opens +
* validates with [token] is [InviteBundleStatus.Live]; anything else present is
* [InviteBundleStatus.Unreadable], and an empty set is [InviteBundleStatus.Absent].
*
@@ -160,7 +160,14 @@ object ConcordInviteBundle {
): InviteBundleStatus {
val newest = wraps.maxByOrNull { it.createdAt } ?: return InviteBundleStatus.Absent
if (newest.tags.vsk() == ControlEntityKind.INVITE_REVOKED) return InviteBundleStatus.Revoked
val invite = wraps.firstNotNullOfOrNull { parse(it, token)?.takeIf { i -> validate(i) } }
// Newest-first, not fetch order. [wraps] arrives straight off `fetchAll`, i.e. in relay
// arrival order, so opening whichever copy decrypts first is a coin flip between editions.
// That matters because a Refounding re-mints every live link at its OWN coordinate with the
// new epoch's root: a relay still serving the pre-Refounding bundle would otherwise hand the
// joiner the root of the epoch the community just left, and they would join, see planes
// nobody reads, and get no error saying why. The revocation branch above already resolves
// newest-wins; the live branch has to agree with it.
val invite = wraps.sortedByDescending { it.createdAt }.firstNotNullOfOrNull { parse(it, token)?.takeIf { i -> validate(i) } }
return when {
invite == null -> InviteBundleStatus.Unreadable
isExpired(invite, nowMs) -> InviteBundleStatus.Expired(invite)
@@ -96,6 +96,28 @@ class ConcordInviteClassifyTest {
assertEquals(InviteBundleStatus.Revoked, ConcordInviteBundle.classify(listOf(tombstone, minted.bundleEvent), minted.token))
}
@Test
fun remintAtTheSameCoordinateWinsOverAStaleCopy() =
runTest {
// The Refounding path (CORD-05 §1): every live link is re-minted at its own coordinate
// carrying the new epoch's root. A relay that still serves the pre-Refounding bundle
// must not be able to hand a joiner the epoch the community just left.
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
val before = inviteFor(community).copy(communityRoot = "aa".repeat(32), rootEpoch = 1L)
val minted = ConcordInviteBundle.mintLink("https://vector.chat", before, createdAt = 1000L)
val after = before.copy(communityRoot = "bb".repeat(32), rootEpoch = 2L)
val remint = ConcordInviteBundle.build(minted.linkSignerPrivKey, minted.token, after, createdAt = 2000L)
// Both fetch orders must resolve to the re-mint — `fetchAll` gives no ordering guarantee.
listOf(listOf(minted.bundleEvent, remint), listOf(remint, minted.bundleEvent)).forEach { wraps ->
val status = ConcordInviteBundle.classify(wraps, minted.token)
assertTrue(status is InviteBundleStatus.Live)
assertEquals("bb".repeat(32), status.invite.communityRoot)
assertEquals(2L, status.invite.rootEpoch)
}
}
@Test
fun unknownSubKindIsUnreadable() =
runTest {