mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 16:33:27 +00:00
feat(concord): add control edition parsing and chain folding
- ControlEdition: parses a verified kind-3308 rumor's vsk/eid/ev/ep/vac tags into a typed edition, computes its domain-separated edition hash, and rejects malformed editions (bad kind, missing eid/ev, unknown vsk, bad hex) - AuthorityCitation: the vac Grant pin an actor claims rank under - EditionFold: folds editions into each entity's current head — genesis anchoring, intact-chain / no-downgrade advancement, deterministic lower-rumor-id tie-break for convergence (authority-weighted tie-break layered in the resolver) Tests cover tag parsing + hash, malformed rejection, genesis prev handling, order-independent chain walk, downgrade/broken-chain refusal, tie-break, and the hold-without-genesis case. Green on :quartz:jvmTest. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
This commit is contained in:
+124
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.concord.cord04Roles
|
||||
|
||||
import com.vitorpamplona.quartz.concord.crypto.EditionHash
|
||||
import com.vitorpamplona.quartz.concord.events.ConcordKinds
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
|
||||
import com.vitorpamplona.quartz.nip01Core.core.firstTagValueAsLong
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
|
||||
/**
|
||||
* The exact Grant edition an actor claims authority under (the `vac` tag,
|
||||
* CORD-04). Verifiers block until they have synced this Grant, then resolve the
|
||||
* actor's rank against it — a demoted member's stale citation is dropped.
|
||||
*/
|
||||
class AuthorityCitation(
|
||||
val grantId: ByteArray,
|
||||
val grantVersion: Long,
|
||||
val grantHash: ByteArray,
|
||||
)
|
||||
|
||||
/**
|
||||
* A single Control Plane edition (a kind-3308 author rumor, CORD-02/04).
|
||||
*
|
||||
* Editions are versioned, chainable state: each carries an entity id ([entityId],
|
||||
* `eid`), a monotonically increasing [version] (`ev`), the [prevHash] of the
|
||||
* previous edition (`ep`, absent for the genesis edition), an optional
|
||||
* [authorityCitation] (`vac`) pinning the Grant the [author] acts under, and the
|
||||
* verbatim entity [content]. Its identity is [hash] — a domain-separated hash of
|
||||
* exactly those fields (see [EditionHash]) — which the next edition cites in `ep`,
|
||||
* forming an unforgeable chain.
|
||||
*/
|
||||
class ControlEdition(
|
||||
val entityKind: ControlEntityKind,
|
||||
val entityId: ByteArray,
|
||||
val version: Long,
|
||||
val prevHash: ByteArray?,
|
||||
val authorityCitation: AuthorityCitation?,
|
||||
val content: String,
|
||||
/** The actor's real pubkey (the seal/rumor author). */
|
||||
val author: String,
|
||||
/** The rumor's event id — the deterministic tie-break key at equal version. */
|
||||
val rumorId: String,
|
||||
val createdAt: Long,
|
||||
) {
|
||||
/** Domain-separated edition identity; the next edition's `ep` cites this. */
|
||||
val hash: ByteArray by lazy { EditionHash.hash(entityId, version, prevHash, content) }
|
||||
|
||||
val entityIdHex: String get() = entityId.toHexKey()
|
||||
val hashHex: String get() = hash.toHexKey()
|
||||
|
||||
companion object {
|
||||
const val TAG_VSK = "vsk"
|
||||
const val TAG_EID = "eid"
|
||||
const val TAG_EV = "ev"
|
||||
const val TAG_EP = "ep"
|
||||
const val TAG_VAC = "vac"
|
||||
|
||||
/**
|
||||
* Parses a decrypted, verified kind-3308 [rumor] (its [author] is the
|
||||
* rumor's pubkey) into a [ControlEdition], or returns null if it is not a
|
||||
* well-formed control edition (unknown/absent `vsk`, missing `eid`/`ev`,
|
||||
* malformed hex, …) so the caller drops it rather than folding garbage.
|
||||
*/
|
||||
fun fromRumor(
|
||||
rumor: Event,
|
||||
author: String = rumor.pubKey,
|
||||
): ControlEdition? {
|
||||
if (rumor.kind != ConcordKinds.CONTROL) return null
|
||||
val entityKind = rumor.tags.firstTagValue(TAG_VSK)?.let { ControlEntityKind.of(it) } ?: return null
|
||||
val entityId =
|
||||
rumor.tags
|
||||
.firstTagValue(TAG_EID)
|
||||
?.hexToByteArrayOrNull()
|
||||
?.takeIf { it.size == 32 } ?: return null
|
||||
val version = rumor.tags.firstTagValueAsLong(TAG_EV) ?: return null
|
||||
if (version < 0) return null
|
||||
|
||||
val prevValue = rumor.tags.firstTagValue(TAG_EP)
|
||||
val prevHash = if (prevValue.isNullOrBlank()) null else prevValue.hexToByteArrayOrNull()?.takeIf { it.size == 32 } ?: return null
|
||||
|
||||
val vacTag = rumor.tags.firstOrNull { it.size >= 4 && it[0] == TAG_VAC }
|
||||
val vac =
|
||||
vacTag?.let {
|
||||
val gid = it[1].hexToByteArrayOrNull()?.takeIf { b -> b.size == 32 } ?: return null
|
||||
val gver = it[2].toLongOrNull() ?: return null
|
||||
val ghash = it[3].hexToByteArrayOrNull()?.takeIf { b -> b.size == 32 } ?: return null
|
||||
AuthorityCitation(gid, gver, ghash)
|
||||
}
|
||||
|
||||
return ControlEdition(
|
||||
entityKind = entityKind,
|
||||
entityId = entityId,
|
||||
version = version,
|
||||
prevHash = prevHash,
|
||||
authorityCitation = vac,
|
||||
content = rumor.content,
|
||||
author = author,
|
||||
rumorId = rumor.id,
|
||||
createdAt = rumor.createdAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.concord.cord04Roles
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
|
||||
/**
|
||||
* Folds Control Plane editions into the current head of each entity (CORD-04
|
||||
* §Edition Hashing & Chain Integrity).
|
||||
*
|
||||
* Rules enforced here:
|
||||
* - **Genesis anchoring** — a chain starts at the lowest-version edition with no
|
||||
* `ep` (prev hash). Without a genesis, the entity has no accepted head (we
|
||||
* hold rather than trust an unrooted later edition).
|
||||
* - **Intact chain / no downgrades** — the head advances to `version + 1` only
|
||||
* when that edition's `ep` cites the current head's [ControlEdition.hash].
|
||||
* Lower or non-chaining versions are ignored.
|
||||
* - **Deterministic convergence** — at equal version, ties break on the lower
|
||||
* rumor id, so every honest client folds to the same head.
|
||||
*
|
||||
* Authority-weighted tie-break ("authority first, then the lower rumor id") and
|
||||
* the owner-rooted `vac` verification are applied by the resolver layer on top of
|
||||
* this structural fold; this class is purely the chain walk.
|
||||
*/
|
||||
object EditionFold {
|
||||
/** Groups mixed [editions] by entity id and folds each to its head. */
|
||||
fun fold(editions: Collection<ControlEdition>): Map<String, ControlEdition> {
|
||||
val byEntity = editions.groupBy { it.entityIdHex }
|
||||
val out = HashMap<String, ControlEdition>(byEntity.size)
|
||||
for ((entity, list) in byEntity) {
|
||||
foldEntity(list)?.let { out[entity] = it }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Folds the editions of a single entity into its current head, or null. */
|
||||
fun foldEntity(editions: List<ControlEdition>): ControlEdition? {
|
||||
if (editions.isEmpty()) return null
|
||||
|
||||
// Index editions by version, keeping the tie-break winner where several
|
||||
// share a version (lower rumor id wins).
|
||||
val byVersion = HashMap<Long, MutableList<ControlEdition>>()
|
||||
for (e in editions) byVersion.getOrPut(e.version) { ArrayList() }.add(e)
|
||||
|
||||
// Genesis: lowest version with no prev hash. Prefer the tie-break winner.
|
||||
var head =
|
||||
editions
|
||||
.filter { it.prevHash == null }
|
||||
.minWithOrNull(compareBy({ it.version }, { it.rumorId }))
|
||||
?: return null
|
||||
|
||||
// Walk the chain upward while the next version chains from the current head.
|
||||
while (true) {
|
||||
val next =
|
||||
byVersion[head.version + 1]
|
||||
?.filter { it.prevHash != null && it.prevHash.toHexKey() == head.hashHex }
|
||||
?.minByOrNull { it.rumorId }
|
||||
?: break
|
||||
head = next
|
||||
}
|
||||
return head
|
||||
}
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.concord.cord04Roles
|
||||
|
||||
import com.vitorpamplona.quartz.concord.crypto.EditionHash
|
||||
import com.vitorpamplona.quartz.concord.events.ConcordKinds
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class ControlEditionTest {
|
||||
private val author = KeyPair().pubKey.toHexKey()
|
||||
private val eid = ByteArray(32) { 0xAB.toByte() }
|
||||
|
||||
private fun edition(
|
||||
version: Long,
|
||||
prevHash: ByteArray?,
|
||||
content: String,
|
||||
rumorId: String,
|
||||
) = ControlEdition(
|
||||
entityKind = ControlEntityKind.CHANNEL,
|
||||
entityId = eid,
|
||||
version = version,
|
||||
prevHash = prevHash,
|
||||
authorityCitation = null,
|
||||
content = content,
|
||||
author = author,
|
||||
rumorId = rumorId,
|
||||
createdAt = 1_700_000_000L + version,
|
||||
)
|
||||
|
||||
// ---- fromRumor ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun fromRumorParsesTagsAndComputesHash() {
|
||||
val prev = ByteArray(32) { 0x01 }
|
||||
val grantId = ByteArray(32) { 0x02 }
|
||||
val grantHash = ByteArray(32) { 0x03 }
|
||||
val content = """{"member":"aa","role_ids":["bb"]}"""
|
||||
val tags =
|
||||
arrayOf(
|
||||
arrayOf("vsk", "3"),
|
||||
arrayOf("eid", eid.toHexKey()),
|
||||
arrayOf("ev", "4"),
|
||||
arrayOf("ep", prev.toHexKey()),
|
||||
arrayOf("vac", grantId.toHexKey(), "2", grantHash.toHexKey()),
|
||||
)
|
||||
val rumor = RumorAssembler.assembleRumor<Event>(author, 1_700_000_000L, ConcordKinds.CONTROL, tags, content)
|
||||
|
||||
val ed = ControlEdition.fromRumor(rumor)
|
||||
assertNotNull(ed)
|
||||
assertEquals(ControlEntityKind.GRANT, ed.entityKind)
|
||||
assertContentEquals(eid, ed.entityId)
|
||||
assertEquals(4, ed.version)
|
||||
assertContentEquals(prev, ed.prevHash)
|
||||
assertEquals(author, ed.author)
|
||||
assertEquals(rumor.id, ed.rumorId)
|
||||
assertContentEquals(EditionHash.hash(eid, 4, prev, content), ed.hash)
|
||||
assertNotNull(ed.authorityCitation)
|
||||
assertContentEquals(grantId, ed.authorityCitation.grantId)
|
||||
assertEquals(2, ed.authorityCitation.grantVersion)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fromRumorRejectsMalformed() {
|
||||
// wrong kind
|
||||
assertNull(ControlEdition.fromRumor(RumorAssembler.assembleRumor<Event>(author, 1L, 9, arrayOf(arrayOf("vsk", "0")), "{}")))
|
||||
// missing eid
|
||||
assertNull(
|
||||
ControlEdition.fromRumor(
|
||||
RumorAssembler.assembleRumor<Event>(author, 1L, ConcordKinds.CONTROL, arrayOf(arrayOf("vsk", "0"), arrayOf("ev", "0")), "{}"),
|
||||
),
|
||||
)
|
||||
// unknown vsk (bit 7 retired)
|
||||
assertNull(
|
||||
ControlEdition.fromRumor(
|
||||
RumorAssembler.assembleRumor<Event>(
|
||||
author,
|
||||
1L,
|
||||
ConcordKinds.CONTROL,
|
||||
arrayOf(arrayOf("vsk", "7"), arrayOf("eid", eid.toHexKey()), arrayOf("ev", "0")),
|
||||
"{}",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun genesisHasNullPrevWhenEpAbsent() {
|
||||
val tags = arrayOf(arrayOf("vsk", "2"), arrayOf("eid", eid.toHexKey()), arrayOf("ev", "0"))
|
||||
val ed = ControlEdition.fromRumor(RumorAssembler.assembleRumor<Event>(author, 1L, ConcordKinds.CONTROL, tags, """{"name":"general"}"""))
|
||||
assertNotNull(ed)
|
||||
assertNull(ed.prevHash)
|
||||
}
|
||||
|
||||
// ---- fold -----------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun foldWalksIntactChainToHead() {
|
||||
val v0 = edition(0, null, """{"name":"general"}""", "id0")
|
||||
val v1 = edition(1, v0.hash, """{"name":"lounge"}""", "id1")
|
||||
val v2 = edition(2, v1.hash, """{"name":"lobby"}""", "id2")
|
||||
// order shuffled to prove fold is order-independent
|
||||
val head = EditionFold.foldEntity(listOf(v2, v0, v1))
|
||||
assertEquals(v2.rumorId, head?.rumorId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun foldStopsAtBreakAndRefusesDowngrade() {
|
||||
val v0 = edition(0, null, """{"name":"general"}""", "id0")
|
||||
// v2 present but v1 missing ⇒ head cannot advance past v0
|
||||
val v2 = edition(2, ByteArray(32) { 0x09 }, """{"name":"lobby"}""", "id2")
|
||||
assertEquals(v0.rumorId, EditionFold.foldEntity(listOf(v0, v2))?.rumorId)
|
||||
|
||||
// v1 with a prev that does not chain from v0 is ignored
|
||||
val badV1 = edition(1, ByteArray(32) { 0x07 }, """{"name":"x"}""", "id1")
|
||||
assertEquals(v0.rumorId, EditionFold.foldEntity(listOf(v0, badV1))?.rumorId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun foldTieBreaksOnLowerRumorId() {
|
||||
val a = edition(0, null, """{"name":"a"}""", "aaa")
|
||||
val b = edition(0, null, """{"name":"b"}""", "bbb")
|
||||
assertEquals("aaa", EditionFold.foldEntity(listOf(b, a))?.rumorId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun foldWithoutGenesisReturnsNull() {
|
||||
val v1 = edition(1, ByteArray(32) { 0x05 }, """{"name":"x"}""", "id1")
|
||||
assertNull(EditionFold.foldEntity(listOf(v1)))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user