From c865d0f4ebf39afdb42c5f56154a241b0f38e2e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 21:51:35 +0000 Subject: [PATCH] feat(concord): add control edition parsing and chain folding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../concord/cord04Roles/ControlEdition.kt | 124 ++++++++++++++ .../quartz/concord/cord04Roles/EditionFold.kt | 81 +++++++++ .../concord/cord04Roles/ControlEditionTest.kt | 156 ++++++++++++++++++ 3 files changed, 361 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEdition.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFold.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEditionTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEdition.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEdition.kt new file mode 100644 index 0000000000..5e36890830 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEdition.kt @@ -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, + ) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFold.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFold.kt new file mode 100644 index 0000000000..99602208e1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFold.kt @@ -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): Map { + val byEntity = editions.groupBy { it.entityIdHex } + val out = HashMap(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? { + 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>() + 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 + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEditionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEditionTest.kt new file mode 100644 index 0000000000..6d3e836b13 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEditionTest.kt @@ -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(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(author, 1L, 9, arrayOf(arrayOf("vsk", "0")), "{}"))) + // missing eid + assertNull( + ControlEdition.fromRumor( + RumorAssembler.assembleRumor(author, 1L, ConcordKinds.CONTROL, arrayOf(arrayOf("vsk", "0"), arrayOf("ev", "0")), "{}"), + ), + ) + // unknown vsk (bit 7 retired) + assertNull( + ControlEdition.fromRumor( + RumorAssembler.assembleRumor( + 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(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))) + } +}