feat(subgroups): say what a key state proposal is asking, and tell a child it has been offered a parent

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 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-10-02 17:57:49 +02:00
parent eba973331b
commit e81659bf3b
6 changed files with 360 additions and 26 deletions

View File

@@ -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(

View File

@@ -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.
*

View File

@@ -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

View File

@@ -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

View File

@@ -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<Array<String>> = SubgroupBirthCertificateEvent.assembleTags(

View File

@@ -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<Array<String>> = emptyArray(),