feat(subgroups): a certificate can say what the room is for

One optional tag -- `["subgroupFor", "<kind>", "<id>"]` -- and it is the whole of
the link between a subgroup and the dialect, artifact, translation or project it
was made for. The certificate is the only artefact in the relationship that is
signed, travels, and is already read on both sides, so the link goes there
rather than in a local column nobody else can see or a room name that is not an
identity.

The signature already covers the tags, since an event id hashes over them, so
nothing is added to the claim by putting it there -- only to what a parent admin
can see before agreeing. That is the same bargain `SubgroupKeyTag` made.

Both halves or neither. An id alone would have to be looked up in four tables to
be understood, and a reader without the kind cannot tell "a project this device
does not hold" from "not a project at all"; a kind alone names a category. The
pair follows `PayloadKindTag`/`PayloadIdTag`, which carry the same two for the
same reason.

The tag sits after the named tags and before the run of `p` tags, and the
position is load bearing rather than incidental: a reader that predates this
takes the tags it knows off the front and ignores the rest, while one inserted
into the admin run would split the founding roster for anybody reading
positionally. `SubgroupLineage` fixes its order for the same kind of reason, and
a test asserts the position rather than trusting the comment.

`certifies` is untouched, and that is the point of the third test. The origin
joins the name and the `p` tags as founding history: a certificate may name a
row this device will never hold, because a subgroup's members need never have
been in the parent, and a certificate with no origin at all is valid because
every one signed before today has none. What the quorum signs is the intention
that the room be for that entity; nothing vouches that the entity exists, and
nothing can.

Phase 4 of docs/subgroups-for-entities.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-10-03 01:30:35 +02:00
parent 2c3c949b86
commit 8d1e341131
3 changed files with 258 additions and 1 deletions

View File

@@ -8,7 +8,9 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import press.mantra.compose.managers.SharedKeyDerivation
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
import press.mantra.compose.nostr.frost.tags.FrostDerivationPathTag
import press.mantra.compose.managers.SubgroupOrigin
import press.mantra.compose.nostr.subgroup.tags.SubgroupKeyTag
import press.mantra.compose.nostr.subgroup.tags.SubgroupOriginTag
import press.mantra.compose.nostr.subgroup.tags.SubgroupParentTag
/**
@@ -59,6 +61,14 @@ import press.mantra.compose.nostr.subgroup.tags.SubgroupParentTag
* somebody joined the child, and the rejection would look exactly like a
* forgery. They are shown as history and are never a live roster.
*
* The origin -- what the subgroup was made *for* -- is in the same position and
* is checked even less. A certificate may name a dialect, artifact, translation
* or project that this device does not hold and never will, because a subgroup's
* members need never have been in the parent; and a certificate with no origin at
* all is valid, which it has to be, since every one signed before the tag existed
* has none. What the parent's quorum signs is the *intention* that the room be
* for that entity. Nothing vouches that the entity exists, and nothing can.
*
* ### Replaceable, by this app rather than by a relay
*
* Like every kind in this family it only ever exists inside an encryption a
@@ -95,13 +105,22 @@ object SubgroupBirthCertificateEvent {
thresholdPublicKey: HexKey,
adminPublicKeys: List<HexKey>,
name: String,
path: List<Long> = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH
path: List<Long> = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH,
origin: SubgroupOrigin.Ref? = null
): Array<Array<String>> = arrayOf(
DTag.assemble(subgroupChatRoomId),
SubgroupParentTag.assemble(parentChatRoomId),
SubgroupKeyTag.assemble(thresholdPublicKey),
FrostDerivationPathTag.assemble(path),
arrayOf(NAME_TAG_NAME, name)
) + listOfNotNull(
// After the named tags and *before* the run of `p` tags, and the
// position is fixed rather than incidental. A reader that predates this
// takes the tags it knows off the front and ignores the rest; one
// inserted into the admin run would split the founding roster for
// anybody reading it positionally. The same reasoning fixes the order of
// a lineage's pairs in `SubgroupLineage`.
origin?.let { SubgroupOriginTag.assemble(kind = it.kind, id = it.id) }
) + adminPublicKeys.distinct().map { PTag.assemble(it, null) }
/** The child this certificate is about, or null if it names none. */
@@ -135,6 +154,24 @@ object SubgroupBirthCertificateEvent {
fun parseName(tags: Array<Array<String>>): String? =
tags.firstOrNull { it.size > 1 && it[0] == NAME_TAG_NAME }?.get(1)?.ifBlank { null }
/**
* The dialect, artifact, translation or project this subgroup was made for,
* or null when it was made for nothing in particular.
*
* Null is the normal answer for two unrelated reasons and neither is a
* failure: a subgroup started from a group's own "Add subgroup" is for no
* entity, and every certificate signed before this tag existed carries none.
* Null for a *malformed* origin too -- see `SubgroupOriginTag.parse` -- since
* half a reference is not a weaker reference.
*
* Never consulted by [certifies]. See the class comment on the founding
* roster: a certificate is signed once, and nothing on it is compared against
* anything current.
*/
fun parseOrigin(tags: Array<Array<String>>): SubgroupOrigin.Ref? =
tags.firstNotNullOfOrNull(SubgroupOriginTag::parse)
?.let { SubgroupOrigin.Ref(kind = it.kind, id = it.id) }
/**
* Whether [event] is a certificate of [subgroupChatRoomId] that
* [parentChatRoomId] actually signed.

View File

@@ -0,0 +1,79 @@
package press.mantra.compose.nostr.subgroup.tags
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* What a subgroup was made for: a kind, and the id of the event.
*
* A group's room holds dialects, artifacts, translations and projects, and a
* subgroup is often made for exactly one of them -- the room where that
* translation gets worked on. This is the tag that says so, and it is the only
* place the link exists: a room's name is not an identity, and a local column
* would be a fact one device holds.
*
* ### Both halves or neither
*
* [parse] refuses a tag with only one value. An id alone would have to be looked
* up in four tables to be understood, and a reader without the kind cannot tell
* "a project this device does not hold" from "not a project at all". A kind
* alone names a category rather than a thing. `SubgroupOrigin.Ref` has no
* constructor for half of this either, which is the same rule stated where a
* caller would otherwise assemble one by hand.
*
* The pair, and the order, follow `PayloadKindTag`/`PayloadIdTag`: a client
* deciding what it is looking at before parsing the thing itself.
*
* ### It authenticates nothing about the entity
*
* On a certificate the parent's quorum is signing that *it intends* the room to
* be for that entity. It is not vouching that the entity exists, that this
* device can resolve it, or that the entity's own signature is good -- which is
* the limit `docs/project-feature.md` states for a cross-room project
* membership, and it is the right limit here for the same reason. A subgroup's
* members need never have been in the parent, so a certificate naming a row they
* will never hold is the normal case rather than an error.
*
* On a ritual proposal it authenticates even less: anybody can put any id there.
* See `DkgRitualEvents.parseOrigin`.
*
* ### `certifies` never looks at it
*
* Deliberately. The name and the `p` tags on a certificate are the *founding*
* roster -- signed once, never compared against anything current -- and the
* origin joins them. A certificate with no origin at all is valid, which it has
* to be: every certificate signed before this feature has none.
*/
class SubgroupOriginTag(
val kind: Kind,
val id: HexKey,
) {
fun toTagArray() = assemble(kind = kind, id = id)
companion object {
const val TAG_NAME = "subgroupFor"
fun parse(tag: Array<String>): SubgroupOriginTag? {
ensure(tag.has(2)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
val kind = tag[1].toIntOrNull() ?: return null
// The length check the nip30303 id tags make. An id of the wrong
// shape is not a thing to carry forward in the hope that something
// downstream refuses it -- nothing downstream would, since nothing
// resolves an origin before showing it.
ensure(tag[2].length == 64) { return null }
return SubgroupOriginTag(kind = kind, id = tag[2])
}
fun assemble(kind: Kind, id: HexKey): Array<String> =
arrayOf(TAG_NAME, kind.toString(), id)
fun assemble(subgroupOriginTag: SubgroupOriginTag) =
assemble(kind = subgroupOriginTag.kind, id = subgroupOriginTag.id)
}
}

View File

@@ -19,6 +19,12 @@ import kotlin.test.assertTrue
import press.mantra.compose.extensions.toHex
import press.mantra.compose.managers.SharedKeyDerivation
import press.mantra.compose.managers.SubgroupManager
import press.mantra.compose.managers.SubgroupOrigin
import press.mantra.compose.nostr.nip30303.ArtifactEvent
import press.mantra.compose.nostr.nip30303.DialectEvent
import press.mantra.compose.nostr.nip30303.ProjectEvent
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
import press.mantra.compose.nostr.subgroup.tags.SubgroupOriginTag
/**
* What a birth certificate is allowed to convince a member of.
@@ -582,6 +588,141 @@ class SubgroupBirthCertificateEventTest {
)
}
// --- what the subgroup is for ---
@Test
fun `an origin round trips for each of the four kinds`() {
listOf(
DialectEvent.KIND,
ArtifactEvent.KIND,
TranslationArtifactVersionEvent.KIND,
ProjectEvent.KIND
).forEach { kind ->
val entityId = "a1".padEnd(64, '0')
val tags = SubgroupBirthCertificateEvent.assembleTags(
subgroupChatRoomId = subgroupChatRoomId,
parentChatRoomId = parentChatRoomId,
thresholdPublicKey = childKey,
adminPublicKeys = admins,
name = name,
path = path,
origin = SubgroupOrigin.Ref(kind = kind, id = entityId)
)
assertEquals(
SubgroupOrigin.Ref(kind = kind, id = entityId),
SubgroupBirthCertificateEvent.parseOrigin(tags),
"kind $kind did not read back"
)
}
}
@Test
fun `a certificate made for nothing in particular has no origin`() {
val tags = SubgroupBirthCertificateEvent.assembleTags(
subgroupChatRoomId = subgroupChatRoomId,
parentChatRoomId = parentChatRoomId,
thresholdPublicKey = childKey,
adminPublicKeys = admins,
name = name,
path = path
)
assertNull(SubgroupBirthCertificateEvent.parseOrigin(tags))
assertTrue(
tags.none { it[0] == SubgroupOriginTag.TAG_NAME },
"an origin tag was written for a subgroup that is for nothing"
)
}
/**
* Half a reference is not a weaker reference. A reader without the kind
* cannot tell a project it does not hold from something that is not a
* project, and an id of the wrong length cannot be resolved by anything --
* nothing downstream would refuse it, because nothing resolves an origin
* before showing it.
*/
@Test
fun `a malformed origin reads as none rather than as half of one`() {
val entityId = "a1".padEnd(64, '0')
listOf(
arrayOf(SubgroupOriginTag.TAG_NAME, "30330"),
arrayOf(SubgroupOriginTag.TAG_NAME, "not-a-kind", entityId),
arrayOf(SubgroupOriginTag.TAG_NAME, "30330", entityId.dropLast(1)),
arrayOf(SubgroupOriginTag.TAG_NAME, "30330", ""),
arrayOf(SubgroupOriginTag.TAG_NAME)
).forEach { malformed ->
assertNull(
SubgroupBirthCertificateEvent.parseOrigin(arrayOf(malformed)),
"${malformed.toList()} parsed as an origin"
)
}
}
/**
* The origin joins the founding roster, and this is the second half of the
* test above that exists to fail loudly. A certificate naming an entity this
* device does not hold still certifies -- a subgroup's members need never
* have been in the parent, so a certificate pointing at a row they will never
* see is the normal case rather than a forgery.
*/
@Test
fun `a certificate for an entity this device cannot resolve still verifies`() {
val signed = certificate(
tags = SubgroupBirthCertificateEvent.assembleTags(
subgroupChatRoomId = subgroupChatRoomId,
parentChatRoomId = parentChatRoomId,
thresholdPublicKey = childKey,
adminPublicKeys = admins,
name = name,
path = path,
origin = SubgroupOrigin.Ref(
kind = ProjectEvent.KIND,
id = "beef".padEnd(64, '0')
)
)
)
assertTrue(
SubgroupBirthCertificateEvent.certifies(
event = signed,
subgroupChatRoomId = subgroupChatRoomId,
parentChatRoomId = parentChatRoomId
)
)
}
/**
* The origin sits before the run of `p` tags, and the position is load
* bearing: a reader that predates it takes the tags it knows off the front,
* and one inserted into the admin run would split the founding roster for
* anybody reading positionally.
*/
@Test
fun `an origin disturbs neither the admins nor the name`() {
val tags = SubgroupBirthCertificateEvent.assembleTags(
subgroupChatRoomId = subgroupChatRoomId,
parentChatRoomId = parentChatRoomId,
thresholdPublicKey = childKey,
adminPublicKeys = admins,
name = name,
path = path,
origin = SubgroupOrigin.Ref(
kind = DialectEvent.KIND,
id = "a1".padEnd(64, '0')
)
)
assertEquals(admins, SubgroupBirthCertificateEvent.parseAdminPublicKeys(tags))
assertEquals(name, SubgroupBirthCertificateEvent.parseName(tags))
val originAt = tags.indexOfFirst { it[0] == SubgroupOriginTag.TAG_NAME }
val firstAdminAt = tags.indexOfFirst { it[0] == "p" }
assertTrue(originAt in 0 until firstAdminAt, "the origin is inside the admin run")
}
private fun certificate(
content: String = subgroupChatRoomId,
tags: Array<Array<String>> = SubgroupBirthCertificateEvent.assembleTags(