From e81659bf3be585cde5d1da3c6a99b272740c1d2c Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 17:57:49 +0200 Subject: [PATCH] feat(subgroups): say what a key state proposal is asking, and tell a child it has been offered a parent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of docs/subgroups-multiple-parents.md, the two consent surfaces. The note calls the first of these the one change in the design that is not plumbing, and it is the only one that would have shipped a member a question they could not read. **`ProposedEvent`'s 30326 arm showed a derivation path and a ceremony id.** That was right while a key state was proposed once, at founding, by the coordinator who chose its lineage: the only question was which key the room signs with. A key state can be re-announced now, and that is exactly how a group is adopted -- its own quorum accepts a parent by signing a state that names one. A member approving that is deciding whether their group should answer to another, and `m/9420/0/0 · a1b2c3d4e5f6` answers a question nobody asked. The proposal is their only chance to check, since after it a quorum has spoken. So the parents lead when there are any, counted when there is more than one, in the lineage's canonical order so two devices describe one proposal the same way. The certificates are already in the tags the signature covers, so this costs a reader nothing it does not hold. What the summary cannot say is which parents are being *dropped*: that is a difference against the row this device holds and a summary is pure, so it belongs to the screen, and there is a comment where somebody will look for it. **And the child's room now hears the offer.** `ChatMessage`'s 30329 arm assumed a certificate in a room was that room's own act, because at founding it always is. An adoption signs the certificate first, in the adopting group, and the child's admins had no notice of it at all -- the first thing they would see is a proposal wanting their signature. Founding at least shows them the intent, since `DkgRitualEvents` puts `parent_group` on the ceremony proposal and the ritual screen names the group; adoption has no ceremony and so no hint. `SubgroupManager.classify` is the decision, pure and needing no database -- `certifies` checks a signature against a room id, and a room id is a public key, so an offer can be believed by a device that has never held the offering group's room. Two true things a certificate can be, and the room it arrived in is the whole of the difference: this room signed it about another, or another signed it about this one. A third room's business is nothing here whoever signed it. `TYPE_SUBGROUP_ADOPTION_OFFERED` writes a line and files nothing. It cannot file anything -- `GroupSignedEvent` declares a foreign key onto `ChatRoom`, so a child device with no row for the parent could not store the event -- and it does not need to, because the certificate reaches these devices for real inside the key state that accepts it. The wording carries that: a subgroup of theirs *only once this group agrees what it signs with again, naming them*. An offer rendered as a fact about the lineage would be the whole gap reopened from the other end. **A test found a real one while writing this.** A group can sign, truthfully by every check `certifies` makes, that it is its own parent: author, subject and named parent one room, the id deriving from the key, the signature verifying. The self-parent guard started out inside the offer branch, where it never ran -- the child branch catches such a certificate first and reads it as an ordinary child, which would put a group in its own list of subgroups. It is refused ahead of both branches now, and the test is named for what it pins rather than for the branch it was written against. That case predates this commit: `subgroupsOf` would have listed it today. Eleven tests. Six for `classify` in `SubgroupBirthCertificateEventTest`, which is where the three real quorums already are and where every case it decides is a question about a certificate; four for the summary in `ProposedEventTest`, including a state claiming a lineage it cannot state, which must read as the key alone rather than guess. One icon arm in `ChatTranscript`, because a subgroup type missing from that mapping renders as a chat bubble and looks exactly like a member having said it. 454 common tests, 947 jvm tests, `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 --- .../compose/database/model/ChatMessage.kt | 95 +++++++++++++++---- .../compose/managers/SubgroupManager.kt | 73 ++++++++++++++ .../mantra/compose/text/ProposedEvent.kt | 40 +++++++- .../composable/widgets/chat/ChatTranscript.kt | 3 +- .../SubgroupBirthCertificateEventTest.kt | 86 +++++++++++++++++ .../mantra/compose/text/ProposedEventTest.kt | 89 +++++++++++++++++ 6 files changed, 360 insertions(+), 26 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt index efa2b704..0cd98645 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt @@ -383,6 +383,26 @@ data class ChatMessage( */ const val TYPE_SUBGROUP_CERTIFIED = "subgroupCertified" + /** + * Another group has certified **this** room as its child. + * + * The other side of the line above, and it appears in the *child's* room. + * A group is founded with a parent and may be adopted by another later -- + * see docs/subgroups-multiple-parents.md -- and an adoption is two + * signings: the new parent certifies, then this room's own quorum + * re-announces its key state accepting it. The parent commits first, so + * this line is the offer, and it is the only notice the child's admins + * get before a proposal asks for their signature. + * + * **An offer rather than a fact about the lineage.** Nothing is adopted + * until this room's quorum says so, and nothing is filed: a child device + * cannot store an event signed by a group it is not in, and does not need + * to. The certificate itself travels inside the key state at the second + * signing. The wording has to carry that, or a member reads it as a + * relationship that already exists. + */ + const val TYPE_SUBGROUP_ADOPTION_OFFERED = "subgroupAdoptionOffered" + /** * Every subgroup line, for the one check the transcript dispatches on. * @@ -392,6 +412,7 @@ data class ChatMessage( */ val SUBGROUP_TYPES = setOf( TYPE_SUBGROUP_CERTIFIED, + TYPE_SUBGROUP_ADOPTION_OFFERED, ) /** @@ -1336,29 +1357,61 @@ data class ChatMessage( // that was ever put to this room -- the child may since have been // renamed, and this line is a record of what was agreed rather // than a view of what is. - SubgroupBirthCertificateEvent.KIND -> { - SubgroupManager.record( - database = database, - chatRoomId = groupId, - innerEvent = event - ) ?: return null + // + // Two true things a certificate can be, and the room it arrived + // in is what tells them apart -- see `SubgroupManager.classify`. + // This room signed it about another, or another signed it about + // this one, which is a parent offering to adopt a group that + // already exists. Anything else is nobody's business here. + SubgroupBirthCertificateEvent.KIND -> + when (val arriving = SubgroupManager.classify(groupId, event)) { + is SubgroupManager.ArrivingCertificate.OurChild -> { + SubgroupManager.record( + database = database, + chatRoomId = groupId, + innerEvent = event + ) ?: return null - val name = SubgroupBirthCertificateEvent.parseName(event.tags) - val admins = SubgroupBirthCertificateEvent.parseAdminPublicKeys(event.tags).size + val name = SubgroupBirthCertificateEvent.parseName(event.tags) + val admins = + SubgroupBirthCertificateEvent.parseAdminPublicKeys(event.tags).size - ChatMessage( - giftWrapPayloadId = null, - messageType = TYPE_SUBGROUP_CERTIFIED, - marmotGroupEventId = marmotGroupEventId, - marmotInnerEventId = marmotInnerEventId, - senderPublicKey = senderPublicKey, - isUserMessage = isUserMessage, - chatRoomId = groupId, - createdAt = createdAt, - content = "The group made ${name ?: "a subgroup"} a subgroup, " + - "run by $admins of its members" - ) - } + ChatMessage( + giftWrapPayloadId = null, + messageType = TYPE_SUBGROUP_CERTIFIED, + marmotGroupEventId = marmotGroupEventId, + marmotInnerEventId = marmotInnerEventId, + senderPublicKey = senderPublicKey, + isUserMessage = isUserMessage, + chatRoomId = groupId, + createdAt = createdAt, + content = "The group made ${name ?: "a subgroup"} a subgroup, " + + "run by $admins of its members" + ) + } + + // Deliberately writes a line and files nothing. The offer + // is real and verified -- that group's quorum signed it -- + // but this room is not adopted until its own quorum says + // so, and the certificate reaches these devices for real + // inside the key state that says it. + is SubgroupManager.ArrivingCertificate.OfferToUs -> ChatMessage( + giftWrapPayloadId = null, + messageType = TYPE_SUBGROUP_ADOPTION_OFFERED, + marmotGroupEventId = marmotGroupEventId, + marmotInnerEventId = marmotInnerEventId, + senderPublicKey = senderPublicKey, + isUserMessage = isUserMessage, + chatRoomId = groupId, + createdAt = createdAt, + content = "${arriving.parentChatRoomId.take(16)} has offered to " + + "adopt this group as a subgroup. It is a subgroup of theirs " + + "only once this group agrees what it signs with again, " + + "naming them." + ) + + null -> return null + } else -> { ChatMessage( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt index 42e57358..26993788 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt @@ -431,6 +431,79 @@ object SubgroupManager { database.groupKeyStateDao().getLocalByChatRoomId(chatRoomId)?.parentChatRoomIds() .orEmpty() + /** + * What a certificate arriving in a room is, or null if it is nothing this + * room should believe. + * + * Pure, and the decision the 30329 dispatch arm cannot make for itself. Two + * true things a certificate can be, and the room it arrived in is what tells + * them apart: + * + * - [OurChild] -- **this** room signed it, about another room. The founding + * case, and the one that was always here. + * - [OfferToUs] -- it is about **this** room, signed by the group it names. + * A parent offering to adopt an existing group, which is the half of an + * adoption the child's members would otherwise never hear about until a + * proposal asked for their signature. + * + * Both are fully verified before they are returned, and neither needs a + * lookup: `certifies` checks a signature against a room id, and a room id is + * a public key. So an offer can be believed on its own merits by a device that + * has never held the offering group's room. + * + * **Neither is stored as the offering group's event.** `GroupSignedEvent` + * declares a foreign key onto `ChatRoom`, so a child device with no row for + * the parent could not file one if it wanted to. It does not need to: the + * certificate reaches the child's devices for real inside the key state the + * child's own quorum signs, and what an offer buys here is notice, not + * evidence. + */ + sealed interface ArrivingCertificate { + /** This room certified [subgroupChatRoomId] as its child. */ + data class OurChild(val subgroupChatRoomId: String) : ArrivingCertificate + + /** [parentChatRoomId] has certified this room as *its* child. */ + data class OfferToUs(val parentChatRoomId: HexKey) : ArrivingCertificate + } + + fun classify(chatRoomId: String, innerEvent: Event): ArrivingCertificate? { + val subgroupChatRoomId = + SubgroupBirthCertificateEvent.parseSubgroupChatRoomId(innerEvent.tags) + ?: return null + + // A certificate names a parent or it is not one; `certifies` would refuse + // it either way, so reading the tag first costs nothing and lets the next + // check come before anything else. + val parentChatRoomId = + SubgroupBirthCertificateEvent.parseParentChatRoomId(innerEvent.tags) ?: return null + + // **A group is not its own child**, and this is the one self-referential + // certificate `certifies` waves through: a group can sign, truthfully by + // every check that function makes, that it is its own parent -- the + // author, the subject and the named parent are one room, the id derives + // from the key, and the signature verifies. It means nothing, and it has + // to be refused before either branch below rather than inside one, or the + // first branch reads it as an ordinary child and the group appears in its + // own list of subgroups. + if (subgroupChatRoomId.equals(parentChatRoomId, ignoreCase = true)) return null + + if (SubgroupBirthCertificateEvent.certifies(innerEvent, subgroupChatRoomId, chatRoomId)) { + return ArrivingCertificate.OurChild(subgroupChatRoomId) + } + + // Only a certificate about *this* room can be an offer to it. Checked + // before the signature so that a certificate about some third room, + // signed by some fourth, cannot be read as anything at all -- it is + // nobody's business in this room whoever signed it. + if (!subgroupChatRoomId.equals(chatRoomId, ignoreCase = true)) return null + + if (!SubgroupBirthCertificateEvent.certifies(innerEvent, chatRoomId, parentChatRoomId)) { + return null + } + + return ArrivingCertificate.OfferToUs(parentChatRoomId) + } + /** * Files a certificate that arrived, or drops it and says why. * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt index 7b11f466..be902063 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt @@ -129,13 +129,45 @@ object ProposedEvent { // its work, and the only one a member sees before the room has done // anything. Shown as the path and the ceremony rather than as the key: // the key is the room's own id, which the member is already looking at. - GroupKeyStateEvent.KIND -> Summary( - label = "This group's shared key", - detail = listOfNotNull( + // + // ### A state naming parents is a different question, and must read like one + // + // A key state used to be proposed once, at founding, by the coordinator + // who chose its lineage -- so the path and a ceremony id were the whole of + // what a signer needed. A state can be **re-announced** now, and that is + // how a group is adopted: its own quorum accepts a parent by signing a + // state that names one. A member asked to approve that is being asked + // whether this group should answer to another, and showing them a + // derivation path answers a question nobody asked. + // + // So the parents lead when there are any. The proposal is their only + // chance to check -- after it a quorum has spoken -- and the certificates + // are right there in the tags the signature covers, so this costs a + // reader nothing it does not already hold. + // + // What this cannot say is which parents are being *dropped*, because a + // summary is pure and a disavowal is a difference against the row this + // device holds. That belongs to the screen, which has the room. + GroupKeyStateEvent.KIND -> { + val parents = GroupKeyStateEvent.parseLineage(event.tags)?.parentChatRoomIds + val shared = listOfNotNull( GroupKeyStateEvent.parsePath(event.tags)?.let(SharedKeyDerivation::formatPath), GroupKeyStateEvent.parseDkgSessionId(event.tags)?.take(12) ).joinToString(" · ") - ) + + if (parents.isNullOrEmpty()) { + Summary(label = "This group's shared key", detail = shared) + } else { + Summary( + label = if (parents.size == 1) { + "This group's shared key, and the group it answers to" + } else { + "This group's shared key, and the ${parents.size} groups it answers to" + }, + detail = (parents.map { it.take(16) } + shared).joinToString(" · ") + ) + } + } // The other thing a group signs that is about groups rather than about // work, and the only one where the *proposal* is the last chance to diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt index ca9c4bc6..bfefd1b6 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt @@ -812,7 +812,8 @@ private fun RitualNotice( // An invite made and an invite sent are two separate steps on the deferred // path, so they get separate icons -- the whole reason both lines exist is // to be able to see that the first happened and the second did not. - ChatMessage.TYPE_SUBGROUP_CERTIFIED -> Icons.Default.AccountTree + ChatMessage.TYPE_SUBGROUP_CERTIFIED, + ChatMessage.TYPE_SUBGROUP_ADOPTION_OFFERED -> Icons.Default.AccountTree ChatMessage.TYPE_MEMBER_INVITED -> Icons.Default.PersonAdd ChatMessage.TYPE_MEMBER_INVITE_SENT -> Icons.Default.Upload ChatMessage.TYPE_MEMBER_INVITE_FAILED -> Icons.Default.ErrorOutline diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt index 8fc32ffd..a625bb39 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt @@ -18,6 +18,7 @@ import kotlin.test.assertNull import kotlin.test.assertTrue import press.mantra.compose.extensions.toHex import press.mantra.compose.managers.SharedKeyDerivation +import press.mantra.compose.managers.SubgroupManager /** * What a birth certificate is allowed to convince a member of. @@ -350,6 +351,91 @@ class SubgroupBirthCertificateEventTest { * way to test that both are checked. [signature] replaces the real one * outright, for the cases where what arrives is not a signature at all. */ + // ---- What an arriving certificate is ------------------------------------ + // + // `SubgroupManager.classify` lives in a manager and is tested here, because + // every case it decides is a question about a certificate and this is where + // the three real quorums are. It needs no database: `certifies` checks a + // signature against a room id, and a room id is a public key. + + @Test + fun `in the parent's room, a certificate is about the room's own child`() { + assertEquals( + SubgroupManager.ArrivingCertificate.OurChild(subgroupChatRoomId), + SubgroupManager.classify(parentChatRoomId, certificate()) + ) + } + + @Test + fun `in the child's room, the same certificate is an offer to adopt it`() { + // The half of an adoption the child's members would otherwise never hear + // about until a proposal asked for their signature. One event, two + // meanings, and the room it arrived in is the whole of the difference. + assertEquals( + SubgroupManager.ArrivingCertificate.OfferToUs(parentChatRoomId), + SubgroupManager.classify(subgroupChatRoomId, certificate()) + ) + } + + @Test + fun `in a third party's room it is nothing at all`() { + val strangerRoom = SharedKeyDerivation.marmotGroupId(strangerKey, path) + + // Neither this room's act nor about this room. Whoever signed it, it is + // nobody's business here, and a line in this transcript would be noise + // about two groups the reader may not be in. + assertNull(SubgroupManager.classify(strangerRoom, certificate())) + } + + @Test + fun `an offer has to be signed by the group it names`() { + // The `parent_group` tag says the parent certified this room; the author + // says who actually signed. A stranger putting another group's name on + // its own signature is the forgery this check exists for. + val forged = certificate(author = strangerMaterial) + + assertNull(SubgroupManager.classify(subgroupChatRoomId, forged)) + } + + @Test + fun `a group is not its own child, however truthfully it says so`() { + // The one self-referential certificate `certifies` waves through: author, + // subject and named parent all one room, the id deriving from the key, + // the signature verifying. Refused before either branch -- read as an + // ordinary child it would put the group in its own list of subgroups. + val itself = certificate( + content = parentChatRoomId, + tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = parentChatRoomId, + parentChatRoomId = parentChatRoomId, + thresholdPublicKey = parentMaterial.thresholdPublicKey.value.toHex(), + adminPublicKeys = admins, + name = name, + path = path + ) + ) + + assertNull(SubgroupManager.classify(parentChatRoomId, itself)) + } + + @Test + fun `an offer must be about this room and not merely arrive in it`() { + val strangerRoom = SharedKeyDerivation.marmotGroupId(strangerKey, path) + val aboutSomebodyElse = certificate( + content = strangerRoom, + tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = strangerRoom, + parentChatRoomId = parentChatRoomId, + thresholdPublicKey = strangerKey, + adminPublicKeys = admins, + name = name, + path = path + ) + ) + + assertNull(SubgroupManager.classify(subgroupChatRoomId, aboutSomebodyElse)) + } + private fun certificate( content: String = subgroupChatRoomId, tags: Array> = SubgroupBirthCertificateEvent.assembleTags( diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt index 9ecddf7e..e8ff4ecf 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt @@ -3,6 +3,10 @@ package press.mantra.compose.text import com.vitorpamplona.quartz.nip01Core.core.Event import kotlin.test.Test import kotlin.test.assertEquals +import press.mantra.compose.nostr.frost.GroupKeyStateEvent +import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent +import press.mantra.compose.nostr.subgroup.SubgroupLineage +import press.mantra.compose.nostr.subgroup.SubgroupParentage /** * How an event a group signs is said to the member signing it. @@ -23,6 +27,91 @@ class ProposedEventTest { assertEquals("An article", summary.detail) } + @Test + fun `a key state with no lineage reads as the key alone`() { + // Every group made before subgroups, and every top-level group made + // after. The path and the ceremony are what a signer needs, because the + // only question is which key the room signs with. + val summary = ProposedEvent.summarize( + event( + GroupKeyStateEvent.KIND, + tags = GroupKeyStateEvent.assembleTags(room, "ceremony-1") + ) + ) + + assertEquals("This group's shared key", summary.label) + assertEquals("m/9420/0/0 · ceremony-1", summary.detail) + } + + @Test + fun `a key state that names a parent says so first`() { + // The one change in this mapping that is not cosmetic. A key state can be + // re-announced now, and that is how a group is adopted -- so a member + // approving this is deciding whether their group should answer to + // another, and a derivation path answers a question nobody asked. + val summary = ProposedEvent.summarize( + event( + GroupKeyStateEvent.KIND, + tags = GroupKeyStateEvent.assembleTags( + chatRoomId = room, + dkgSessionId = "ceremony-1", + lineage = SubgroupLineage.of(parentage(parent)) + ) + ) + ) + + assertEquals("This group's shared key, and the group it answers to", summary.label) + assertEquals("${parent.take(16)} · m/9420/0/0 · ceremony-1", summary.detail) + } + + @Test + fun `a key state naming two parents counts them`() { + val summary = ProposedEvent.summarize( + event( + GroupKeyStateEvent.KIND, + tags = GroupKeyStateEvent.assembleTags( + chatRoomId = room, + dkgSessionId = "ceremony-1", + lineage = SubgroupLineage.of(listOf(parentage(parent), parentage(second))) + ) + ) + ) + + assertEquals("This group's shared key, and the 2 groups it answers to", summary.label) + // In the lineage's canonical order, so two devices describe one proposal + // the same way. + assertEquals( + "${parent.take(16)} · ${second.take(16)} · m/9420/0/0 · ceremony-1", + summary.detail + ) + } + + @Test + fun `a key state claiming a lineage it cannot state reads as the key alone`() { + // A half-claim, which `GroupKeyStateManager.stateFrom` would drop whole. + // The summary cannot say "and one group" about a parent it could not + // read, so it says nothing about lineage rather than guessing. + val summary = ProposedEvent.summarize( + event( + GroupKeyStateEvent.KIND, + tags = GroupKeyStateEvent.assembleTags(room, "ceremony-1") + + arrayOf(arrayOf("parent_group", parent)) + ) + ) + + assertEquals("This group's shared key", summary.label) + } + + private val room = "1".repeat(64) + private val parent = "a".repeat(64) + private val second = "b".repeat(64) + + /** A parentage whose certificate is nonsense; this mapping checks nothing. */ + private fun parentage(parentChatRoomId: String) = SubgroupParentage( + parentChatRoomId = parentChatRoomId, + certificate = event(SubgroupBirthCertificateEvent.KIND, content = parentChatRoomId) + ) + private fun event( kind: Int, tags: Array> = emptyArray(),