diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifact.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifact.kt index a8194481..6ccf2fdd 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifact.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifact.kt @@ -4,11 +4,13 @@ import androidx.room3.Entity import androidx.room3.ForeignKey import androidx.room3.PrimaryKey import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip31Alts.AltTag import press.mantra.compose.database.model.traits.OptionalNostrEventEntity +import press.mantra.compose.database.model.traits.SubgroupOriginEntity import press.mantra.compose.database.model.traits.TimestampedEntity import press.mantra.compose.nostr.nip30303.ArtifactEvent import press.mantra.compose.nostr.nip30303.tags.ArtifactVersionMetadataTag @@ -40,16 +42,16 @@ import kotlin.time.Instant ) data class MantraArtifact( @PrimaryKey - val id: HexKey, + override val id: HexKey, val publicKey: String, - val name: String, + override val name: String, val url: String, // Hash this to get the dTag... val visibility: String, // public, private, permissioned (public, but only members can contribute) val dialectId: String, val license: String, // val artifactApprovalId: String, - val chatRoomId: String, + override val chatRoomId: String, /** * Signature will be an empty string if this is a rumor @@ -68,7 +70,16 @@ data class MantraArtifact( override val nostrEventId: HexKey? = null, override val createdAt: Instant = Clock.System.now(), override val updatedAt: Instant = createdAt -): OptionalNostrEventEntity, TimestampedEntity { +): OptionalNostrEventEntity, SubgroupOriginEntity, TimestampedEntity { + + /** + * Which kind a reader holding only this row's id is looking at. + * + * A computed property, so it is not a column and Room never sees it -- + * the kind is a fact about the type rather than about the row, and a + * stored copy could disagree with the event this row came from. + */ + override val subgroupOriginKind: Kind get() = ArtifactEvent.KIND /** * The event the group signed, rebuilt from this row. * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraDialect.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraDialect.kt index 0bc37ba8..a6dc0bae 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraDialect.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraDialect.kt @@ -4,11 +4,13 @@ import androidx.room3.Entity import androidx.room3.ForeignKey import androidx.room3.PrimaryKey import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip31Alts.AltTag import press.mantra.compose.database.model.traits.OptionalNostrEventEntity +import press.mantra.compose.database.model.traits.SubgroupOriginEntity import press.mantra.compose.database.model.traits.TimestampedEntity import press.mantra.compose.nostr.nip30303.DialectEvent import press.mantra.compose.nostr.nip30303.tags.CountryTag @@ -32,15 +34,15 @@ import kotlin.time.Instant ) data class MantraDialect( @PrimaryKey - val id: HexKey, + override val id: HexKey, val publicKey: HexKey, - val name: String, + override val name: String, val country: String, val language: String, val signature: String, - val chatRoomId: String, + override val chatRoomId: String, val relayHint: String? = null, val marmotGroupEventId: HexKey? = null, @@ -54,7 +56,16 @@ data class MantraDialect( override val nostrEventId: HexKey? = null, override val createdAt: Instant = Clock.System.now(), override val updatedAt: Instant = createdAt -): OptionalNostrEventEntity, TimestampedEntity { +): OptionalNostrEventEntity, SubgroupOriginEntity, TimestampedEntity { + + /** + * Which kind a reader holding only this row's id is looking at. + * + * A computed property, so it is not a column and Room never sees it -- + * the kind is a fact about the type rather than about the row, and a + * stored copy could disagree with the event this row came from. + */ + override val subgroupOriginKind: Kind get() = DialectEvent.KIND @Deprecated( "Chronicle fallback for work signed before the GroupSignedEvent table. Read the " + diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraProject.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraProject.kt index abbf505f..34ab0055 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraProject.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraProject.kt @@ -5,9 +5,11 @@ import androidx.room3.ForeignKey import androidx.room3.Index import androidx.room3.PrimaryKey import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import press.mantra.compose.database.model.traits.OptionalNostrEventEntity +import press.mantra.compose.database.model.traits.SubgroupOriginEntity import press.mantra.compose.database.model.traits.TimestampedEntity import press.mantra.compose.nostr.nip30303.ProjectEvent import kotlin.time.Clock @@ -39,12 +41,12 @@ import kotlin.time.Instant ) data class MantraProject( @PrimaryKey - val id: HexKey, + override val id: HexKey, /** The room's own key on a group-signed row, which is every project. */ val publicKey: HexKey, - val name: String, + override val name: String, val description: String, /** @@ -73,7 +75,7 @@ data class MantraProject( */ val ownerPublicKeys: String, - val chatRoomId: String, + override val chatRoomId: String, /** Signature will be an empty string if this is a rumor */ val signature: String, @@ -89,7 +91,16 @@ data class MantraProject( override val createdAt: Instant = Clock.System.now(), override val updatedAt: Instant = createdAt -): OptionalNostrEventEntity, TimestampedEntity { +): OptionalNostrEventEntity, SubgroupOriginEntity, TimestampedEntity { + + /** + * Which kind a reader holding only this row's id is looking at. + * + * A computed property, so it is not a column and Room never sees it -- + * the kind is a fact about the type rather than about the row, and a + * stored copy could disagree with the event this row came from. + */ + override val subgroupOriginKind: Kind get() = ProjectEvent.KIND /** The owners as the set they are. Empty only on a row written around this. */ fun ownerPublicKeySet(): Set = ownerPublicKeys diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraTranslationArtifactVersion.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraTranslationArtifactVersion.kt index 06f05451..eaaeb5f4 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraTranslationArtifactVersion.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraTranslationArtifactVersion.kt @@ -4,11 +4,13 @@ import androidx.room3.Entity import androidx.room3.ForeignKey import androidx.room3.PrimaryKey import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip31Alts.AltTag import press.mantra.compose.database.model.traits.OptionalNostrEventEntity +import press.mantra.compose.database.model.traits.SubgroupOriginEntity import press.mantra.compose.database.model.traits.TimestampedEntity import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.tags.ArtifactVersionIdTag @@ -46,11 +48,11 @@ import kotlin.time.Instant ) data class MantraTranslationArtifactVersion( @PrimaryKey - val id: HexKey, + override val id: HexKey, val publicKey: HexKey, val artifactVersionId: HexKey, val dialectId: HexKey, - val name: String, + override val name: String, val visibility: String, val license: String, @@ -59,7 +61,7 @@ data class MantraTranslationArtifactVersion( */ val signature: String, - val chatRoomId: String, + override val chatRoomId: String, val relayHint: String? = null, val marmotGroupEventId: HexKey? = null, @@ -73,7 +75,16 @@ data class MantraTranslationArtifactVersion( override val nostrEventId: HexKey? = null, override val createdAt: Instant = Clock.System.now(), override val updatedAt: Instant = createdAt -): OptionalNostrEventEntity, TimestampedEntity { +): OptionalNostrEventEntity, SubgroupOriginEntity, TimestampedEntity { + + /** + * Which kind a reader holding only this row's id is looking at. + * + * A computed property, so it is not a column and Room never sees it -- + * the kind is a fact about the type rather than about the row, and a + * stored copy could disagree with the event this row came from. + */ + override val subgroupOriginKind: Kind get() = TranslationArtifactVersionEvent.KIND @Deprecated( "Chronicle fallback for work signed before the GroupSignedEvent table. Read the " + diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/traits/SubgroupOriginEntity.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/traits/SubgroupOriginEntity.kt new file mode 100644 index 00000000..676f8e8e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/traits/SubgroupOriginEntity.kt @@ -0,0 +1,51 @@ +package press.mantra.compose.database.model.traits + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind + +/** + * A thing a group can decide deserves a room of its own. + * + * Four rows in four tables -- a dialect, an artifact, a translation, a project -- + * already carry the same three fields, and a subgroup made for any of them needs + * exactly those three: the room it lives in becomes the parent, its name becomes + * the proposed name, and its id and kind become what the parent's quorum + * certifies the new room is *for*. + * + * It is an interface rather than four overloads of `SubgroupOrigin.of` so that + * "which things can a subgroup be made for" is a question the compiler answers. + * A fifth entity gains the capability by implementing this, and the picker, the + * certificate and both directions of the UI pick it up without being told. + * + * ### Why `name` is here and is not an identity + * + * [name] is for naming a room and nothing else. It is deliberately not treated + * as identifying the entity anywhere: a dialect's identity is its + * `(language, country)` pair, since nothing in the schema makes a dialect unique + * -- the id is a content hash including `createdAt`, there is no index, and the + * rows are quorum-signed so they cannot be rewritten. Two rooms that typed + * "Southern Sotho" and "Sesotho" hold the same dialect under two names. So the + * link a subgroup carries is [id] and [subgroupOriginKind], and the name is only + * ever what the room is called. + * + * ### Why the kind is on the row + * + * A reader holding an id alone would have to try four tables to find out what it + * is looking at, and could not tell "a project this device does not hold" from + * "not a project at all". Every one of these rows already knows its own event + * kind -- most of them build one in `toXEvent()` -- so stating it costs a line + * and saves the lookup. See `SubgroupOrigin`. + */ +interface SubgroupOriginEntity { + /** The entity's own id: a nostr event id, content-hashed. */ + val id: HexKey + + /** What to call the room. Never read as an identity -- see the class comment. */ + val name: String + + /** The room this entity lives in, which is the room that would be the parent. */ + val chatRoomId: String + + /** The nip30303 kind of the event this row is, for a reader holding only the id. */ + val subgroupOriginKind: Kind +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupOrigin.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupOrigin.kt new file mode 100644 index 00000000..12b27866 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupOrigin.kt @@ -0,0 +1,78 @@ +package press.mantra.compose.managers + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import press.mantra.compose.database.model.traits.SubgroupOriginEntity + +/** + * What a subgroup was made for, and everything a picker needs to start one. + * + * Making a room for a dialect, an artifact, a translation or a project takes + * three facts, and all three are already sitting on the row the user was looking + * at when they asked: the room it lives in is the parent, its name is the name to + * propose, and its id and kind are what the parent's quorum certifies the new + * room is for. [of] reads them, and that is the whole of the convenience. + * + * Pure, and parallel to `TranslationScaffold` and `ChapterScaffolding` -- no + * database, no Compose, no repository. Nothing here opens a ceremony: the picker + * still does that through `DkgRepository.proposeRitual`, and every refusal is + * still `SubgroupManager.refuseSubgroup`'s. What this removes is a user retyping + * a name the app already knew and a room ending up related to its entity by + * having been named after it. + * + * See `docs/subgroups-for-entities.md`. + */ +object SubgroupOrigin { + /** + * The entity a subgroup is for: a kind and an id, together or not at all. + * + * There is no constructor that holds one of the two, which is the point of + * the type. An id with no kind is unresolvable without trying four tables; a + * kind with no id names a category rather than a thing. `SubgroupOriginTag` + * refuses to parse half of this for the same reason. + * + * Holding one is not evidence of anything. The parent's quorum signs that it + * *intends* a room to be for this entity; it does not vouch that the entity + * exists, and a device that holds the certificate routinely holds none of the + * parent's rows -- a subgroup's members need never have been in the parent. + */ + data class Ref( + val kind: Kind, + val id: HexKey, + ) + + /** + * Everything the subgroup picker needs, read off [entity]. + * + * [Candidate.name] is bare -- no `#`. The convention is applied by + * `MarmotGroupName.of` where the name is actually used, twice and + * idempotently: once into the birth certificate the parent's quorum signs and + * once onto the room itself. Normalising here as well would be a third place + * that has to agree, and the one it would most easily disagree with is the + * name field the user is still typing in. + * + * A blank name comes back blank rather than becoming "a group" or the + * entity's id. The picker already refuses to confirm without a name -- "Give + * the subgroup a name." -- and inventing one here would route around a + * refusal whose whole job is to be hit. + */ + fun of(entity: SubgroupOriginEntity): Candidate = Candidate( + parentChatRoomId = entity.chatRoomId, + name = entity.name.trim(), + origin = Ref(kind = entity.subgroupOriginKind, id = entity.id), + ) + + /** + * A subgroup that could be made, before anybody has picked who runs it. + * + * Deliberately not a proposal and not a session. Who the admins are and what + * the threshold is are settled in the picker and nowhere else -- ChillDKG + * hashes the threshold into the session identity, so there is no later screen + * where it could change -- and this type stops short of all of it. + */ + data class Candidate( + val parentChatRoomId: String, + val name: String, + val origin: Ref, + ) +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SubgroupOriginTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SubgroupOriginTest.kt new file mode 100644 index 00000000..e39c4157 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SubgroupOriginTest.kt @@ -0,0 +1,160 @@ +package press.mantra.compose.managers + +import kotlin.test.Test +import kotlin.test.assertEquals +import press.mantra.compose.database.model.MantraArtifact +import press.mantra.compose.database.model.MantraDialect +import press.mantra.compose.database.model.MantraProject +import press.mantra.compose.database.model.MantraTranslationArtifactVersion +import press.mantra.compose.database.model.traits.SubgroupOriginEntity +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 + +/** + * Reading a subgroup out of the thing it is for. + * + * Four entities, one answer each, and the answer is three fields the row already + * carried. What these cases are really guarding is that the three keep meaning + * the same thing: the parent is the room the entity lives in and never the + * entity's id, the name is bare and un-normalised, and the kind is the entity's + * own kind rather than a subgroup kind. + */ +class SubgroupOriginTest { + + private val room = "r".repeat(64) + private val entityId = "e".repeat(64) + private val key = "p".repeat(64) + + private fun dialect(name: String = "Sesotho") = MantraDialect( + id = entityId, + publicKey = key, + name = name, + country = "Lesotho", + language = "st", + signature = "", + chatRoomId = room, + ) + + private fun artifact(name: String = "Luke") = MantraArtifact( + id = entityId, + publicKey = key, + name = name, + url = "https://example.invalid/luke", + visibility = "public", + dialectId = "d".repeat(64), + license = "CC0", + chatRoomId = room, + signature = "", + ) + + private fun translation(name: String = "Setswana Luke") = MantraTranslationArtifactVersion( + id = entityId, + publicKey = key, + artifactVersionId = "v".repeat(64), + dialectId = "d".repeat(64), + name = name, + visibility = "public", + license = "CC0", + signature = "", + chatRoomId = room, + ) + + private fun project(name: String = "Southern Africa") = MantraProject( + id = entityId, + publicKey = key, + name = name, + description = "Four dialects, one gospel.", + creatorPublicKey = key, + ownerPublicKeys = key, + chatRoomId = room, + signature = "", + ) + + private fun assertCandidate(entity: SubgroupOriginEntity, kind: Int, name: String) { + val candidate = SubgroupOrigin.of(entity) + + assertEquals(room, candidate.parentChatRoomId) + assertEquals(name, candidate.name) + assertEquals(SubgroupOrigin.Ref(kind = kind, id = entityId), candidate.origin) + } + + @Test + fun `a dialect names its room, its name and kind 30304`() { + assertCandidate(dialect(), DialectEvent.KIND, "Sesotho") + } + + @Test + fun `an artifact names its room, its name and kind 30300`() { + assertCandidate(artifact(), ArtifactEvent.KIND, "Luke") + } + + @Test + fun `a translation names its room, its name and kind 30306`() { + assertCandidate(translation(), TranslationArtifactVersionEvent.KIND, "Setswana Luke") + } + + @Test + fun `a project names its room, its name and kind 30330`() { + assertCandidate(project(), ProjectEvent.KIND, "Southern Africa") + } + + /** + * The four kinds are four, which is the whole reason the ref carries one. An + * id alone would have to be looked up in four tables, and this is the case + * that fails if somebody gives two entities the same kind by copying a + * sibling. + */ + @Test + fun `the four kinds are distinct`() { + val kinds = listOf(dialect(), artifact(), translation(), project()) + .map { SubgroupOrigin.of(it).origin.kind } + + assertEquals(4, kinds.distinct().size, "two entities report the same kind: $kinds") + } + + /** + * Bare, with no `#`. `MarmotGroupName.of` adds it where the name is used -- + * into the certificate the parent signs, and onto the room -- and it is + * idempotent, so a name normalised here as well would still come out right. + * It is kept out anyway because this name goes into a text field the user is + * about to type in, and the `#` is drawn beside that field rather than put + * inside it. + */ + @Test + fun `the name carries no group prefix`() { + assertEquals("Sesotho", SubgroupOrigin.of(dialect()).name) + assertEquals("#Sesotho", MarmotGroupName.of(SubgroupOrigin.of(dialect()).name)) + } + + @Test + fun `surrounding space is trimmed`() { + assertEquals("Sesotho", SubgroupOrigin.of(dialect(" Sesotho ")).name) + } + + /** + * A blank name stays blank rather than becoming "a group" or the entity's id. + * The picker refuses to confirm without one -- "Give the subgroup a name." -- + * and inventing a name here would route around a refusal whose whole job is + * to be hit. + */ + @Test + fun `a blank name is handed back blank, not invented`() { + assertEquals("", SubgroupOrigin.of(dialect(" ")).name) + assertEquals("", SubgroupOrigin.of(project("")).name) + } + + /** + * The parent is the room, not the entity. Easy to get backwards -- both are + * 64-hex strings on the same row -- and getting it backwards would open a + * ceremony in a room that does not exist, which fails late and unhelpfully. + */ + @Test + fun `the parent is the room the entity lives in`() { + val candidate = SubgroupOrigin.of(artifact()) + + assertEquals(room, candidate.parentChatRoomId) + assertEquals(entityId, candidate.origin.id) + } +}