fix(concord): keep at most 32 retired keys per private channel

`priors` gained an entry per rotation and was never pruned. A List entry
that outgrows its fragment fails every List write (joins and leaves
too), which is worse than losing the oldest era's history. Keep the 32
newest epochs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-09-30 10:11:21 -04:00
co-authored by Claude Opus 5.5
parent c0c671663c
commit 6ba62525b3
2 changed files with 22 additions and 1 deletions
@@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
@@ -61,6 +62,9 @@ object ConcordChannelKeyring {
const val CHANNEL_CUTS = "channel_cuts"
const val PRIORS = "priors"
/** How many retired keys a channel keeps in `priors`, newest epochs first. */
const val MAX_PRIORS = 32
private val HEX64 = Regex("^[0-9a-fA-F]{64}$")
/** The current key held for [channelIdHex], or null (a keyless listing is not a key). */
@@ -185,9 +189,15 @@ object ConcordChannelKeyring {
JsonObject(mapOf("key" to JsonPrimitive(k), "epoch" to JsonPrimitive(epoch), "retired_at" to JsonPrimitive(at)))
}
if (added.isEmpty()) return extras
return JsonObject(extras + (PRIORS to JsonArray(existing + added)))
// Bounded: every rotation adds one, and a List entry that outgrows its fragment fails every
// List write (joins and leaves included), which is worse than losing the oldest era's history.
val all = existing + added
val kept = if (all.size <= MAX_PRIORS) all else all.sortedByDescending { priorEpoch(it) }.take(MAX_PRIORS)
return JsonObject(extras + (PRIORS to JsonArray(kept)))
}
private fun priorEpoch(prior: JsonElement): Long = ((prior as? JsonObject)?.get("epoch") as? JsonPrimitive)?.longOrNull ?: Long.MIN_VALUE
/**
* [entry] after a rotation to [cutEpoch] cut this member from [channelIdHex] (CORD-06 §2): the
* key leaves `channels` and the cut is recorded, so no older key can come back.
@@ -145,6 +145,17 @@ class ConcordChannelKeyringTest {
assertFalse(ConcordChannelKeyring.historicalKeys(rotated, chan).any { it.key == "13".repeat(32) })
}
@Test
fun priorsKeepOnlyTheNewestEpochs() {
var e = entry(listOf(PrivateChannelKey(chan, 1.toString(16).padStart(64, '0'), 1, "mods")))
for (epoch in 2L..(ConcordChannelKeyring.MAX_PRIORS + 10)) {
e = assertNotNull(ConcordChannelKeyring.withRotatedKey(e, chan, epoch.toString(16).padStart(64, '0'), epoch, retiredAt = epoch))
}
val epochs = ConcordChannelKeyring.historicalKeys(e, chan).map { it.epoch }
assertEquals(ConcordChannelKeyring.MAX_PRIORS, epochs.size)
assertEquals(ConcordChannelKeyring.MAX_PRIORS + 9L, epochs.first())
}
@Test
fun aRotationKeepsEveryEarlierKeyReadableAfterARoundTrip() {
// A channel created after the join has no `seed` anchor: the List is the only place its