feat(subgroups): schema 23 -- what a room is for survives the ceremony
Two nullable DkgSession columns and one tag on the ritual proposal, so the thing a subgroup is being made for reaches the certificate. It has to travel this way rather than in the coordinator's head. The certificate is proposed *after* the ceremony and by whoever is able to -- any parent admin holding a share, who may never have seen the screen that started it -- which is why every step of a subgroup reads stored rows rather than a session handed down from a screen. The origin is one more row to read. And because it rides the proposal, every participant's session carries it and not only the coordinator's: an admin asked for the certificate reads what the room is for off their own session instead of taking the proposer's word. That is the same road `subject` and `participantPublicKeys` took in v19, for the same reason, and the migration comment says so. Neither column is a foreign key, following parentChatRoomId's precedent in v17 and with a sharper version of its reason: these entities live in the *parent's* tables, and a subgroup's admins need never have been in the parent, so naming a row this device does not hold is the normal state. A cascade would also mean deleting a project deletes a room. There is a test for the dangling case, since nothing in the schema would otherwise say it was intended. It authenticates nothing. Anybody can put any kind and any id on a proposal, as with the parent beside it, and `parseOrigin`'s comment says so where a reader will be standing. The claim that decides anything is the certificate, which the parent's quorum signs. One deviation from the plan, which said `proposeBirthCertificate` would take an `origin` parameter and the view model would pass it. It already takes the whole `DkgSession`, so it reads `key.origin()` instead -- a parameter would have been a second source for one fact, and the proposer's copy of it at that. Nothing in the repository or the ritual view model had to change. Schema 23 collides with an unmerged branch holding 23, 24 and 25; Phase 0.2 of docs/subgroups-for-entities.md records why this takes 23 anyway. Phase 5 of docs/subgroups-for-entities.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8d1e341131
commit
c77187e78d
11 files changed
+6440
-26
No files matched your search
@@ -188,7 +188,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
|
||||
UnsignedNostrEvent::class,
|
||||
Zap::class
|
||||
],
|
||||
version = 22,
|
||||
version = 23,
|
||||
autoMigrations = [
|
||||
// v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can
|
||||
// generate the migration itself — nothing existing changes shape.
|
||||
@@ -364,6 +364,31 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
|
||||
// than a fourth table, which is what DkgSession.participantPublicKeys
|
||||
// already does with a set of keys and for the same reason.
|
||||
AutoMigration(from = 21, to = 22),
|
||||
// v23 adds two nullable DkgSession columns saying what the subgroup a
|
||||
// ceremony is running for is *for* -- the kind and id of a dialect, an
|
||||
// artifact, a translation or a project. See
|
||||
// docs/subgroups-for-entities.md.
|
||||
//
|
||||
// They are here rather than only on the certificate because the
|
||||
// certificate is proposed *after* the ceremony, and by whoever is able
|
||||
// to: any parent admin holding a share, who may never have seen the
|
||||
// screen that started it. Every step of a subgroup reads stored rows for
|
||||
// that reason, and this is one more row to read. They come off the ritual
|
||||
// proposal, so every participant's session has them and not just the
|
||||
// coordinator's -- the same route `subject` and `participantPublicKeys`
|
||||
// took in v19.
|
||||
//
|
||||
// **Neither is a foreign key**, following parentChatRoomId's precedent in
|
||||
// v17 and for a sharper version of its reason: these entities live in the
|
||||
// *parent's* tables, and a subgroup's admins need never have been in the
|
||||
// parent, so a value naming a row this device does not hold is the normal
|
||||
// state rather than an error. A cascade would also mean deleting a
|
||||
// project deletes a room.
|
||||
//
|
||||
// Rows written before this read back null, which is the true answer for
|
||||
// all of them: a ceremony that ran before this column existed was opened
|
||||
// for no entity. Nullable additions are a shape Room migrates itself.
|
||||
AutoMigration(from = 22, to = 23),
|
||||
]
|
||||
)
|
||||
@ColumnTypeConverters(MantraConverters::class)
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.room3.ForeignKey
|
||||
import androidx.room3.Index
|
||||
import androidx.room3.PrimaryKey
|
||||
import press.mantra.compose.database.model.traits.LocalStoreEntity
|
||||
import press.mantra.compose.managers.SubgroupOrigin
|
||||
import press.mantra.compose.database.model.traits.TimestampedEntity
|
||||
import press.mantra.compose.database.model.types.DkgApprovalStep
|
||||
import press.mantra.compose.database.model.types.DkgRitualStage
|
||||
@@ -193,12 +194,53 @@ data class DkgSession(
|
||||
*/
|
||||
val parentChatRoomId: HexKey? = null,
|
||||
|
||||
/**
|
||||
* The kind of the thing this subgroup is being made for, or null for a
|
||||
* subgroup that is for nothing in particular.
|
||||
*
|
||||
* The pair with [originId], and read together or not at all -- see
|
||||
* [origin]. Both come off the ritual proposal, so every participant's
|
||||
* session carries them and not only the coordinator's, which is what makes
|
||||
* the certificate proposable by somebody who was never on the picker screen.
|
||||
*
|
||||
* **This authenticates nothing**, exactly as [parentChatRoomId] does not:
|
||||
* anybody can put any id on a proposal. What it is for is the certificate
|
||||
* two steps later, which the parent's quorum signs, and the screens that say
|
||||
* what a ceremony is for while it runs. Nothing may be granted on the
|
||||
* strength of these two columns.
|
||||
*
|
||||
* Not foreign keys. The row named here is routinely one this device does not
|
||||
* hold -- a subgroup's admins need never have been in the parent, and these
|
||||
* entities live in the parent's tables -- so a dangling value is the normal
|
||||
* state rather than an error, and a cascade would mean deleting a project
|
||||
* deletes a room.
|
||||
*/
|
||||
val originKind: Int? = null,
|
||||
|
||||
/** The id of the thing this subgroup is for. The pair with [originKind]. */
|
||||
val originId: HexKey? = null,
|
||||
|
||||
override val createdAt: Instant = Clock.System.now(),
|
||||
override val updatedAt: Instant = createdAt,
|
||||
override val savedAt: Instant = createdAt,
|
||||
): TimestampedEntity, LocalStoreEntity {
|
||||
fun isCoordinator(): Boolean = coordinatorPublicKey == userPublicKey
|
||||
|
||||
/**
|
||||
* What this subgroup is for, or null when it is for nothing in particular.
|
||||
*
|
||||
* Both columns or neither: half a reference is a reference with the
|
||||
* resolvable part removed, and a row holding one is a row written by a
|
||||
* version that disagreed with this one. Reading them through here rather than
|
||||
* separately is what stops a caller assembling half of one.
|
||||
*/
|
||||
fun origin(): SubgroupOrigin.Ref? {
|
||||
val kind = originKind ?: return null
|
||||
val id = originId ?: return null
|
||||
|
||||
return SubgroupOrigin.Ref(kind = kind, id = id)
|
||||
}
|
||||
|
||||
/** The participants' public shares in participant order, or null if unrecorded. */
|
||||
fun publicShareList(): List<PublicKey>? = publicShares
|
||||
?.split(",")
|
||||
|
||||
+5
-2
@@ -13,6 +13,7 @@ import press.mantra.compose.database.model.types.DkgApprovalStep
|
||||
import press.mantra.compose.managers.ChillDkgRitualManager
|
||||
import press.mantra.compose.managers.GroupKeyStateManager
|
||||
import press.mantra.compose.managers.SubgroupManager
|
||||
import press.mantra.compose.managers.SubgroupOrigin
|
||||
import press.mantra.compose.managers.SharedKeyDerivation
|
||||
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
|
||||
import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent
|
||||
@@ -64,7 +65,8 @@ class DatabaseDkgRepository(
|
||||
threshold: Int,
|
||||
parentChatRoomId: HexKey?,
|
||||
participantPublicKeys: Set<HexKey>?,
|
||||
subject: String?
|
||||
subject: String?,
|
||||
origin: SubgroupOrigin.Ref?
|
||||
): DkgSession? = try {
|
||||
ChillDkgRitualManager.proposeRitual(
|
||||
database = database,
|
||||
@@ -74,7 +76,8 @@ class DatabaseDkgRepository(
|
||||
threshold = threshold,
|
||||
parentChatRoomId = parentChatRoomId,
|
||||
participantPublicKeys = participantPublicKeys,
|
||||
subject = subject
|
||||
subject = subject,
|
||||
origin = origin
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
logger.e("Error proposing DKG ritual for ${localChatRoom.chatRoom.id}", e)
|
||||
|
||||
+29
-6
@@ -183,7 +183,8 @@ object ChillDkgRitualManager {
|
||||
threshold: Int,
|
||||
parentChatRoomId: HexKey? = null,
|
||||
participantPublicKeys: Set<HexKey>? = null,
|
||||
subject: String? = null
|
||||
subject: String? = null,
|
||||
origin: SubgroupOrigin.Ref? = null
|
||||
): DkgSession {
|
||||
// Built the way a receiver rebuilds it from the proposal — the p-tags
|
||||
// `broadcast` writes, plus this device — so both sides count the same `n`
|
||||
@@ -288,7 +289,14 @@ object ChillDkgRitualManager {
|
||||
// proposal -- unverified there and unverified here, and read for
|
||||
// nothing but a screen's title and a way back into an unfinished
|
||||
// subgroup flow. See `DkgSession.parentChatRoomId`.
|
||||
parentChatRoomId = parentChatRoomId
|
||||
parentChatRoomId = parentChatRoomId,
|
||||
// And what the subgroup is for, on the same terms: the coordinator's
|
||||
// own copy of a claim they are about to put on the proposal,
|
||||
// unverified on both sides. It is here rather than only in the
|
||||
// coordinator's head because the certificate is proposed later and
|
||||
// by whoever can -- see `DkgSession.originKind`.
|
||||
originKind = origin?.kind,
|
||||
originId = origin?.id
|
||||
)
|
||||
database.dkgSessionDao().upsert(session)
|
||||
announceStarted(database, session)
|
||||
@@ -315,7 +323,12 @@ object ChillDkgRitualManager {
|
||||
// is routinely that payload. For a subgroup it is the only way the
|
||||
// name travels at all, since the child has no room yet and the room
|
||||
// this runs in is the parent's.
|
||||
subject = ceremonySubject
|
||||
subject = ceremonySubject,
|
||||
// Only on the proposal, for the reason `includeParent` gives: a later
|
||||
// message repeating it would be bytes per round to restate something
|
||||
// settled, and would give a later message a say in what an opened
|
||||
// ceremony is for.
|
||||
includeOrigin = true
|
||||
)
|
||||
|
||||
publishHostKey(database, localChatRoom, session)
|
||||
@@ -471,7 +484,15 @@ object ChillDkgRitualManager {
|
||||
// is the p-tags and the threshold is the threshold. What it changes is
|
||||
// that the screen can say what the ceremony is for. The parent's own
|
||||
// signature turns up two steps later, on the birth certificate.
|
||||
parentChatRoomId = DkgRitualEvents.parseParentChatRoomId(innerEvent.tags)
|
||||
parentChatRoomId = DkgRitualEvents.parseParentChatRoomId(innerEvent.tags),
|
||||
// Taken as said, on exactly the terms the parent above is taken as
|
||||
// said. A proposal naming a project this member has never heard of
|
||||
// opens the same ceremony it would have opened with no tag at all;
|
||||
// what it changes is that the screen can say what the room is for,
|
||||
// and that an admin asked for the certificate later reads it off
|
||||
// their own session rather than off the proposer's word.
|
||||
originKind = DkgRitualEvents.parseOrigin(innerEvent.tags)?.kind,
|
||||
originId = DkgRitualEvents.parseOrigin(innerEvent.tags)?.id
|
||||
)
|
||||
database.dkgSessionDao().upsert(session)
|
||||
announceStarted(database, session)
|
||||
@@ -1352,7 +1373,8 @@ object ChillDkgRitualManager {
|
||||
content: String,
|
||||
includeThreshold: Boolean = false,
|
||||
includeParent: Boolean = false,
|
||||
subject: String? = null
|
||||
subject: String? = null,
|
||||
includeOrigin: Boolean = false
|
||||
) {
|
||||
// Sorted so a receiver comparing two devices' proposals is comparing the
|
||||
// same bytes, and the sender is implied rather than tagged -- together
|
||||
@@ -1374,7 +1396,8 @@ object ChillDkgRitualManager {
|
||||
sessionId = session.id,
|
||||
threshold = if (includeThreshold) session.threshold else null,
|
||||
parentChatRoomId = if (includeParent) session.parentChatRoomId else null,
|
||||
subject = subject
|
||||
subject = subject,
|
||||
origin = if (includeOrigin) session.origin() else null
|
||||
)
|
||||
|
||||
val createdAt = Clock.System.now().epochSeconds
|
||||
|
||||
@@ -254,7 +254,14 @@ object SubgroupManager {
|
||||
thresholdPublicKey = thresholdPublicKey,
|
||||
adminPublicKeys = adminPublicKeys,
|
||||
name = subgroupName,
|
||||
path = path
|
||||
path = path,
|
||||
// Off the ceremony, not off a parameter. [key] is already the
|
||||
// stored session, and every step after the ceremony is open to
|
||||
// somebody who was never on the screen that started it -- so
|
||||
// what the room is for has to come from the same row the key
|
||||
// does. A parameter here would be a second source for one fact,
|
||||
// and the proposer's copy of it at that.
|
||||
origin = key.origin()
|
||||
),
|
||||
content = subgroupChatRoomId
|
||||
)
|
||||
|
||||
@@ -4,6 +4,8 @@ import press.mantra.compose.nostr.dkg.tags.DkgSessionIdTag
|
||||
import press.mantra.compose.nostr.dkg.tags.DkgThresholdTag
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Kind
|
||||
import com.vitorpamplona.quartz.nip14Subject.SubjectTag
|
||||
import press.mantra.compose.managers.SubgroupOrigin
|
||||
import press.mantra.compose.nostr.subgroup.tags.SubgroupOriginTag
|
||||
import press.mantra.compose.nostr.subgroup.tags.SubgroupParentTag
|
||||
|
||||
/**
|
||||
@@ -76,12 +78,14 @@ object DkgRitualEvents {
|
||||
sessionId: String,
|
||||
threshold: Int? = null,
|
||||
parentChatRoomId: String? = null,
|
||||
subject: String? = null
|
||||
subject: String? = null,
|
||||
origin: SubgroupOrigin.Ref? = null
|
||||
): Array<Array<String>> = buildList {
|
||||
add(DkgSessionIdTag.assemble(sessionId))
|
||||
threshold?.let { add(DkgThresholdTag.assemble(it)) }
|
||||
parentChatRoomId?.let { add(SubgroupParentTag.assemble(it)) }
|
||||
subject?.ifBlank { null }?.let { add(SubjectTag.assemble(it)) }
|
||||
origin?.let { add(SubgroupOriginTag.assemble(kind = it.kind, id = it.id)) }
|
||||
}.toTypedArray()
|
||||
|
||||
fun parseSessionId(tags: Array<Array<String>>): String? =
|
||||
@@ -124,4 +128,26 @@ object DkgRitualEvents {
|
||||
*/
|
||||
fun parseSubject(tags: Array<Array<String>>): String? =
|
||||
tags.firstNotNullOfOrNull(SubjectTag::parse)
|
||||
|
||||
/**
|
||||
* The dialect, artifact, translation or project the room this ceremony will
|
||||
* make is for, or null for a subgroup that is for nothing in particular.
|
||||
*
|
||||
* **This authenticates nothing**, in exactly the way
|
||||
* [parseParentChatRoomId] does not: anybody can put any kind and any id
|
||||
* here, and nothing about the entity is checked when it is read. It is filed
|
||||
* on the session by `acceptProposal` and shown by the screens, and that is
|
||||
* the whole of what it is for. The claim that decides anything is the birth
|
||||
* certificate, which the parent's own quorum signs.
|
||||
*
|
||||
* The reason to carry it is the reason the parent is carried. An admin asked
|
||||
* to hold a key ceremony learns from this what the room is being made for,
|
||||
* and later an admin asked to put the group's signature to a certificate
|
||||
* reads it off their own stored session rather than taking the proposer's
|
||||
* word for it. Being told is worth having even when the telling is not
|
||||
* evidence.
|
||||
*/
|
||||
fun parseOrigin(tags: Array<Array<String>>): SubgroupOrigin.Ref? =
|
||||
tags.firstNotNullOfOrNull(SubgroupOriginTag::parse)
|
||||
?.let { SubgroupOrigin.Ref(kind = it.kind, id = it.id) }
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import press.mantra.compose.database.model.GroupSignedEvent
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.database.model.intermdiate.LocalFrostSigningSession
|
||||
import press.mantra.compose.database.model.types.DkgApprovalStep
|
||||
import press.mantra.compose.managers.SubgroupOrigin
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
@@ -98,7 +99,9 @@ interface DkgRepository {
|
||||
threshold: Int,
|
||||
parentChatRoomId: HexKey? = null,
|
||||
participantPublicKeys: Set<HexKey>? = null,
|
||||
subject: String? = null
|
||||
subject: String? = null,
|
||||
/** What the subgroup is for, where it is for anything in particular. */
|
||||
origin: SubgroupOrigin.Ref? = null
|
||||
): DkgSession?
|
||||
|
||||
/** What the ritual is waiting on this device's owner for, if anything. */
|
||||
@@ -235,7 +238,8 @@ interface DkgRepository {
|
||||
threshold: Int,
|
||||
parentChatRoomId: HexKey?,
|
||||
participantPublicKeys: Set<HexKey>?,
|
||||
subject: String?
|
||||
subject: String?,
|
||||
origin: SubgroupOrigin.Ref?
|
||||
): DkgSession? = null
|
||||
|
||||
override suspend fun pendingApproval(session: DkgSession): DkgApprovalStep? = null
|
||||
|
||||
+8
-10
@@ -268,16 +268,14 @@ class SelectSubgroupAdminsViewModel(
|
||||
// coordinator is added by `proposeRitual`: opening a ceremony is
|
||||
// being in it, and is not theirs to tick.
|
||||
participantPublicKeys = selectedPublicKeys.toSet(),
|
||||
subject = name.value.trim()
|
||||
// [origin] is not on the ceremony yet. It will be, and for the
|
||||
// same reason the name is: the certificate is proposed after the
|
||||
// ceremony, possibly by an admin who was never on this screen, so
|
||||
// anything it needs has to be on the stored session rather than
|
||||
// in this lambda. That needs a `DkgSession` column and a tag on
|
||||
// the proposal, which is Phase 5 of
|
||||
// docs/subgroups-for-entities.md. Until then a subgroup made from
|
||||
// an entity is named after it and nothing more, which is exactly
|
||||
// the cheap version of this feature.
|
||||
subject = name.value.trim(),
|
||||
// What the room is for, carried the same way and for the same
|
||||
// reason as the name: the certificate is proposed after the
|
||||
// ceremony, possibly by an admin who was never on this screen,
|
||||
// so it reads what it needs off the stored session. Null here is
|
||||
// a subgroup for nothing in particular, which is the room's own
|
||||
// "Add subgroup" and a perfectly ordinary subgroup.
|
||||
origin = origin
|
||||
)
|
||||
|
||||
isActionPending.value = false
|
||||
|
||||
Reference in new issue
Block a user