diff --git a/composeApp/schemas/press.mantra.compose.database.MantraDatabase/21.json b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/21.json
index a21a6651..c387bc7a 100644
--- a/composeApp/schemas/press.mantra.compose.database.MantraDatabase/21.json
+++ b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/21.json
@@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 21,
- "identityHash": "ccbc98e82860c74663f3faed72cd1395",
+ "identityHash": "ff296ccd92cdb4bd61b3e150565f59c4",
"entities": [
{
"tableName": "BroadcastNostrEventReceipt",
@@ -671,7 +671,7 @@
},
{
"tableName": "ChatRoom",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `subject` TEXT, `description` TEXT, `mlsGroupState` TEXT, `initialGiftWrapPayloadId` TEXT, `leftGroupAt` INTEGER, `joinedGroupAt` INTEGER, `chronicleRequestedAt` INTEGER, `parentChatRoomId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`userPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`initialGiftWrapPayloadId`) REFERENCES `GiftWrapPayload`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `subject` TEXT, `description` TEXT, `mlsGroupState` TEXT, `initialGiftWrapPayloadId` TEXT, `leftGroupAt` INTEGER, `joinedGroupAt` INTEGER, `chronicleRequestedAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`userPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`initialGiftWrapPayloadId`) REFERENCES `GiftWrapPayload`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
@@ -720,11 +720,6 @@
"columnName": "chronicleRequestedAt",
"affinity": "INTEGER"
},
- {
- "fieldPath": "parentChatRoomId",
- "columnName": "parentChatRoomId",
- "affinity": "TEXT"
- },
{
"fieldPath": "createdAt",
"columnName": "createdAt",
@@ -1619,7 +1614,7 @@
},
{
"tableName": "GroupKeyState",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatRoomId` TEXT NOT NULL, `dkgSessionId` TEXT NOT NULL, `thresholdPublicKey` TEXT NOT NULL, `derivationPath` TEXT NOT NULL, `announcedBy` TEXT NOT NULL, `announcedAt` INTEGER NOT NULL, `parentChatRoomId` TEXT, `birthCertificateJson` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`chatRoomId`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatRoomId` TEXT NOT NULL, `dkgSessionId` TEXT NOT NULL, `thresholdPublicKey` TEXT NOT NULL, `derivationPath` TEXT NOT NULL, `announcedBy` TEXT NOT NULL, `announcedAt` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`chatRoomId`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "chatRoomId",
@@ -1657,16 +1652,6 @@
"affinity": "INTEGER",
"notNull": true
},
- {
- "fieldPath": "parentChatRoomId",
- "columnName": "parentChatRoomId",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "birthCertificateJson",
- "columnName": "birthCertificateJson",
- "affinity": "TEXT"
- },
{
"fieldPath": "createdAt",
"columnName": "createdAt",
@@ -1717,6 +1702,61 @@
}
]
},
+ {
+ "tableName": "GroupKeyStateParent",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatRoomId` TEXT NOT NULL, `parentChatRoomId` TEXT NOT NULL, `birthCertificateJson` TEXT NOT NULL, PRIMARY KEY(`chatRoomId`, `parentChatRoomId`), FOREIGN KEY(`chatRoomId`) REFERENCES `GroupKeyState`(`chatRoomId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "chatRoomId",
+ "columnName": "chatRoomId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "parentChatRoomId",
+ "columnName": "parentChatRoomId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "birthCertificateJson",
+ "columnName": "birthCertificateJson",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "chatRoomId",
+ "parentChatRoomId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_GroupKeyStateParent_parentChatRoomId",
+ "unique": false,
+ "columnNames": [
+ "parentChatRoomId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupKeyStateParent_parentChatRoomId` ON `${TABLE_NAME}` (`parentChatRoomId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "GroupKeyState",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "chatRoomId"
+ ],
+ "referencedColumns": [
+ "chatRoomId"
+ ]
+ }
+ ]
+ },
{
"tableName": "GroupSignedEvent",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `signature` TEXT NOT NULL, `derivationPath` TEXT, `frostSigningSessionId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
@@ -2541,410 +2581,6 @@
}
]
},
- {
- "tableName": "MantraProject",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `creatorPublicKey` TEXT NOT NULL, `ownerPublicKeys` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
- "fields": [
- {
- "fieldPath": "id",
- "columnName": "id",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "publicKey",
- "columnName": "publicKey",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "name",
- "columnName": "name",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "description",
- "columnName": "description",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "creatorPublicKey",
- "columnName": "creatorPublicKey",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "ownerPublicKeys",
- "columnName": "ownerPublicKeys",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "chatRoomId",
- "columnName": "chatRoomId",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "signature",
- "columnName": "signature",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "relayHint",
- "columnName": "relayHint",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "marmotGroupEventId",
- "columnName": "marmotGroupEventId",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "groupSignedEventId",
- "columnName": "groupSignedEventId",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "nostrEventId",
- "columnName": "nostrEventId",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "createdAt",
- "columnName": "createdAt",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "updatedAt",
- "columnName": "updatedAt",
- "affinity": "INTEGER",
- "notNull": true
- }
- ],
- "primaryKey": {
- "autoGenerate": false,
- "columnNames": [
- "id"
- ]
- },
- "indices": [
- {
- "name": "index_MantraProject_chatRoomId",
- "unique": false,
- "columnNames": [
- "chatRoomId"
- ],
- "orders": [],
- "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProject_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)"
- }
- ],
- "foreignKeys": [
- {
- "table": "ChatRoom",
- "onDelete": "CASCADE",
- "onUpdate": "NO ACTION",
- "columns": [
- "chatRoomId"
- ],
- "referencedColumns": [
- "id"
- ]
- }
- ]
- },
- {
- "tableName": "MantraProjectArtifactVersion",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `projectId` TEXT NOT NULL, `artifactVersionId` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`projectId`) REFERENCES `MantraProject`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artifactVersionId`) REFERENCES `MantraArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
- "fields": [
- {
- "fieldPath": "id",
- "columnName": "id",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "publicKey",
- "columnName": "publicKey",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "projectId",
- "columnName": "projectId",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "artifactVersionId",
- "columnName": "artifactVersionId",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "chatRoomId",
- "columnName": "chatRoomId",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "signature",
- "columnName": "signature",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "relayHint",
- "columnName": "relayHint",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "marmotGroupEventId",
- "columnName": "marmotGroupEventId",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "groupSignedEventId",
- "columnName": "groupSignedEventId",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "nostrEventId",
- "columnName": "nostrEventId",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "createdAt",
- "columnName": "createdAt",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "updatedAt",
- "columnName": "updatedAt",
- "affinity": "INTEGER",
- "notNull": true
- }
- ],
- "primaryKey": {
- "autoGenerate": false,
- "columnNames": [
- "id"
- ]
- },
- "indices": [
- {
- "name": "index_MantraProjectArtifactVersion_projectId",
- "unique": false,
- "columnNames": [
- "projectId"
- ],
- "orders": [],
- "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectArtifactVersion_projectId` ON `${TABLE_NAME}` (`projectId`)"
- },
- {
- "name": "index_MantraProjectArtifactVersion_artifactVersionId",
- "unique": false,
- "columnNames": [
- "artifactVersionId"
- ],
- "orders": [],
- "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectArtifactVersion_artifactVersionId` ON `${TABLE_NAME}` (`artifactVersionId`)"
- },
- {
- "name": "index_MantraProjectArtifactVersion_chatRoomId",
- "unique": false,
- "columnNames": [
- "chatRoomId"
- ],
- "orders": [],
- "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectArtifactVersion_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)"
- }
- ],
- "foreignKeys": [
- {
- "table": "MantraProject",
- "onDelete": "CASCADE",
- "onUpdate": "NO ACTION",
- "columns": [
- "projectId"
- ],
- "referencedColumns": [
- "id"
- ]
- },
- {
- "table": "MantraArtifactVersion",
- "onDelete": "CASCADE",
- "onUpdate": "NO ACTION",
- "columns": [
- "artifactVersionId"
- ],
- "referencedColumns": [
- "id"
- ]
- },
- {
- "table": "ChatRoom",
- "onDelete": "CASCADE",
- "onUpdate": "NO ACTION",
- "columns": [
- "chatRoomId"
- ],
- "referencedColumns": [
- "id"
- ]
- }
- ]
- },
- {
- "tableName": "MantraProjectTranslationArtifactVersion",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `projectId` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`projectId`) REFERENCES `MantraProject`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
- "fields": [
- {
- "fieldPath": "id",
- "columnName": "id",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "publicKey",
- "columnName": "publicKey",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "projectId",
- "columnName": "projectId",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "translationArtifactVersionId",
- "columnName": "translationArtifactVersionId",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "chatRoomId",
- "columnName": "chatRoomId",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "signature",
- "columnName": "signature",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "relayHint",
- "columnName": "relayHint",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "marmotGroupEventId",
- "columnName": "marmotGroupEventId",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "groupSignedEventId",
- "columnName": "groupSignedEventId",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "nostrEventId",
- "columnName": "nostrEventId",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "createdAt",
- "columnName": "createdAt",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "updatedAt",
- "columnName": "updatedAt",
- "affinity": "INTEGER",
- "notNull": true
- }
- ],
- "primaryKey": {
- "autoGenerate": false,
- "columnNames": [
- "id"
- ]
- },
- "indices": [
- {
- "name": "index_MantraProjectTranslationArtifactVersion_projectId",
- "unique": false,
- "columnNames": [
- "projectId"
- ],
- "orders": [],
- "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectTranslationArtifactVersion_projectId` ON `${TABLE_NAME}` (`projectId`)"
- },
- {
- "name": "index_MantraProjectTranslationArtifactVersion_translationArtifactVersionId",
- "unique": false,
- "columnNames": [
- "translationArtifactVersionId"
- ],
- "orders": [],
- "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectTranslationArtifactVersion_translationArtifactVersionId` ON `${TABLE_NAME}` (`translationArtifactVersionId`)"
- },
- {
- "name": "index_MantraProjectTranslationArtifactVersion_chatRoomId",
- "unique": false,
- "columnNames": [
- "chatRoomId"
- ],
- "orders": [],
- "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectTranslationArtifactVersion_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)"
- }
- ],
- "foreignKeys": [
- {
- "table": "MantraProject",
- "onDelete": "CASCADE",
- "onUpdate": "NO ACTION",
- "columns": [
- "projectId"
- ],
- "referencedColumns": [
- "id"
- ]
- },
- {
- "table": "MantraTranslationArtifactVersion",
- "onDelete": "CASCADE",
- "onUpdate": "NO ACTION",
- "columns": [
- "translationArtifactVersionId"
- ],
- "referencedColumns": [
- "id"
- ]
- },
- {
- "table": "ChatRoom",
- "onDelete": "CASCADE",
- "onUpdate": "NO ACTION",
- "columns": [
- "chatRoomId"
- ],
- "referencedColumns": [
- "id"
- ]
- }
- ]
- },
{
"tableName": "MantraTranslation",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationChunkId` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `text` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationChunkId`) REFERENCES `MantraTranslationChunk`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
@@ -6096,7 +5732,7 @@
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
- "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ccbc98e82860c74663f3faed72cd1395')"
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ff296ccd92cdb4bd61b3e150565f59c4')"
]
}
}
\ No newline at end of file
diff --git a/composeApp/schemas/press.mantra.compose.database.MantraDatabase/22.json b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/22.json
new file mode 100644
index 00000000..cd29f234
--- /dev/null
+++ b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/22.json
@@ -0,0 +1,6142 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 22,
+ "identityHash": "eea30c363f8d70e31d7f5fa306650a58",
+ "entities": [
+ {
+ "tableName": "BroadcastNostrEventReceipt",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `nostrEventId` TEXT NOT NULL, `unsignedNostrEventId` INTEGER, `isAccepted` INTEGER NOT NULL, `isSync` INTEGER NOT NULL, `relayURL` TEXT NOT NULL, `messages` TEXT, FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`unsignedNostrEventId`) REFERENCES `UnsignedNostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "nostrEventId",
+ "columnName": "nostrEventId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "unsignedNostrEventId",
+ "columnName": "unsignedNostrEventId",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "isAccepted",
+ "columnName": "isAccepted",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isSync",
+ "columnName": "isSync",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "relayURL",
+ "columnName": "relayURL",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "messages",
+ "columnName": "messages",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_BroadcastNostrEventReceipt_nostrEventId",
+ "unique": false,
+ "columnNames": [
+ "nostrEventId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventReceipt_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)"
+ },
+ {
+ "name": "index_BroadcastNostrEventReceipt_isAccepted",
+ "unique": false,
+ "columnNames": [
+ "isAccepted"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventReceipt_isAccepted` ON `${TABLE_NAME}` (`isAccepted`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "NostrEvent",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "nostrEventId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "UnsignedNostrEvent",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "unsignedNostrEventId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "BroadcastNostrEventRequest",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `nostrEventId` TEXT NOT NULL, `unsignedNostrEventId` INTEGER, `status` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`unsignedNostrEventId`) REFERENCES `UnsignedNostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "nostrEventId",
+ "columnName": "nostrEventId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "unsignedNostrEventId",
+ "columnName": "unsignedNostrEventId",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "relayURL",
+ "columnName": "relayURL",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_BroadcastNostrEventRequest_nostrEventId",
+ "unique": false,
+ "columnNames": [
+ "nostrEventId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventRequest_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)"
+ },
+ {
+ "name": "index_BroadcastNostrEventRequest_status",
+ "unique": false,
+ "columnNames": [
+ "status"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventRequest_status` ON `${TABLE_NAME}` (`status`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "NostrEvent",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "nostrEventId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "UnsignedNostrEvent",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "unsignedNostrEventId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "Connection",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourcePublicKey` TEXT NOT NULL, `destinationPublicKey` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`sourcePublicKey`, `destinationPublicKey`), FOREIGN KEY(`sourcePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`destinationPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "sourcePublicKey",
+ "columnName": "sourcePublicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "destinationPublicKey",
+ "columnName": "destinationPublicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "nostrEventId",
+ "columnName": "nostrEventId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "savedAt",
+ "columnName": "savedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deletedAt",
+ "columnName": "deletedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "broadcastedAt",
+ "columnName": "broadcastedAt",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "sourcePublicKey",
+ "destinationPublicKey"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "Profile",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "sourcePublicKey"
+ ],
+ "referencedColumns": [
+ "publicKey"
+ ]
+ },
+ {
+ "table": "Profile",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "destinationPublicKey"
+ ],
+ "referencedColumns": [
+ "publicKey"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "ChatMessage",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `senderPublicKey` TEXT NOT NULL, `isUserMessage` INTEGER NOT NULL, `giftWrapPayloadId` TEXT, `marmotGroupEventId` TEXT, `marmotInnerEventId` TEXT, `chatRoomId` TEXT NOT NULL, `replyToMessageId` INTEGER, `quotedMessageId` INTEGER, `content` TEXT NOT NULL, `messageType` TEXT NOT NULL, `directMessageRecipientPublicKey` TEXT, `frostSigningSessionId` TEXT, `dkgSessionId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`giftWrapPayloadId`) REFERENCES `GiftWrapPayload`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`marmotGroupEventId`) REFERENCES `MarmotGroupEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`marmotInnerEventId`) REFERENCES `MarmotInnerEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "senderPublicKey",
+ "columnName": "senderPublicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isUserMessage",
+ "columnName": "isUserMessage",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "giftWrapPayloadId",
+ "columnName": "giftWrapPayloadId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "marmotGroupEventId",
+ "columnName": "marmotGroupEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "marmotInnerEventId",
+ "columnName": "marmotInnerEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "chatRoomId",
+ "columnName": "chatRoomId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "replyToMessageId",
+ "columnName": "replyToMessageId",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "quotedMessageId",
+ "columnName": "quotedMessageId",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "content",
+ "columnName": "content",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "messageType",
+ "columnName": "messageType",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "directMessageRecipientPublicKey",
+ "columnName": "directMessageRecipientPublicKey",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "frostSigningSessionId",
+ "columnName": "frostSigningSessionId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "dkgSessionId",
+ "columnName": "dkgSessionId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "savedAt",
+ "columnName": "savedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "viewedAt",
+ "columnName": "viewedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "deletedAt",
+ "columnName": "deletedAt",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_ChatMessage_chatRoomId",
+ "unique": false,
+ "columnNames": [
+ "chatRoomId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_ChatMessage_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "ChatRoom",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "chatRoomId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "GiftWrapPayload",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "giftWrapPayloadId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "MarmotGroupEvent",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "marmotGroupEventId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "MarmotInnerEvent",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "marmotInnerEventId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "ChatMessageBroadcastNostrEventRequestRelation",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatMessageId` INTEGER NOT NULL, `broadcastNostrEventRequestId` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, FOREIGN KEY(`chatMessageId`) REFERENCES `ChatMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`broadcastNostrEventRequestId`) REFERENCES `BroadcastNostrEventRequest`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chatMessageId",
+ "columnName": "chatMessageId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "broadcastNostrEventRequestId",
+ "columnName": "broadcastNostrEventRequestId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "savedAt",
+ "columnName": "savedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "ChatMessage",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "chatMessageId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "BroadcastNostrEventRequest",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "broadcastNostrEventRequestId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "ChatMessageBroadcastNostrEventReceiptRelation",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatMessageId` INTEGER NOT NULL, `broadcastNostrEventReceiptId` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, FOREIGN KEY(`chatMessageId`) REFERENCES `ChatMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`broadcastNostrEventReceiptId`) REFERENCES `BroadcastNostrEventReceipt`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chatMessageId",
+ "columnName": "chatMessageId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "broadcastNostrEventReceiptId",
+ "columnName": "broadcastNostrEventReceiptId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "savedAt",
+ "columnName": "savedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "ChatMessage",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "chatMessageId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "BroadcastNostrEventReceipt",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "broadcastNostrEventReceiptId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "ChatMessageNostrEventRelation",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatMessageId` INTEGER NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, FOREIGN KEY(`chatMessageId`) REFERENCES `ChatMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chatMessageId",
+ "columnName": "chatMessageId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "nostrEventId",
+ "columnName": "nostrEventId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "savedAt",
+ "columnName": "savedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "ChatMessage",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "chatMessageId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "NostrEvent",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "nostrEventId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "ChatRoom",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `subject` TEXT, `description` TEXT, `mlsGroupState` TEXT, `initialGiftWrapPayloadId` TEXT, `leftGroupAt` INTEGER, `joinedGroupAt` INTEGER, `chronicleRequestedAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`userPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`initialGiftWrapPayloadId`) REFERENCES `GiftWrapPayload`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userPublicKey",
+ "columnName": "userPublicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "subject",
+ "columnName": "subject",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "description",
+ "columnName": "description",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "mlsGroupState",
+ "columnName": "mlsGroupState",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "initialGiftWrapPayloadId",
+ "columnName": "initialGiftWrapPayloadId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "leftGroupAt",
+ "columnName": "leftGroupAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "joinedGroupAt",
+ "columnName": "joinedGroupAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "chronicleRequestedAt",
+ "columnName": "chronicleRequestedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "savedAt",
+ "columnName": "savedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "viewedAt",
+ "columnName": "viewedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "deletedAt",
+ "columnName": "deletedAt",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "Profile",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "userPublicKey"
+ ],
+ "referencedColumns": [
+ "publicKey"
+ ]
+ },
+ {
+ "table": "GiftWrapPayload",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "initialGiftWrapPayloadId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "DkgParticipantMessage",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `participantPublicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `payload` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`sessionId`, `participantPublicKey`, `kind`), FOREIGN KEY(`sessionId`) REFERENCES `DkgSession`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "sessionId",
+ "columnName": "sessionId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "participantPublicKey",
+ "columnName": "participantPublicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "kind",
+ "columnName": "kind",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "payload",
+ "columnName": "payload",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "sessionId",
+ "participantPublicKey",
+ "kind"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_DkgParticipantMessage_sessionId",
+ "unique": false,
+ "columnNames": [
+ "sessionId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_DkgParticipantMessage_sessionId` ON `${TABLE_NAME}` (`sessionId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "DkgSession",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "sessionId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "DkgSession",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `coordinatorPublicKey` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `threshold` INTEGER NOT NULL, `participantCount` INTEGER NOT NULL, `participantPublicKeys` TEXT, `subject` TEXT, `stage` TEXT NOT NULL, `hostPublicKey` TEXT NOT NULL, `round1Random` TEXT NOT NULL, `round2AuxRandom` TEXT NOT NULL, `coordinatorRound1` TEXT, `certificate` TEXT, `thresholdPublicKey` TEXT, `secretShare` TEXT, `publicShares` TEXT, `recoveryData` TEXT, `failureReason` TEXT, `hostKeyApprovedAt` INTEGER, `round1ApprovedAt` INTEGER, `round2ApprovedAt` INTEGER, `approvalRequestedThrough` TEXT, `parentChatRoomId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chatRoomId",
+ "columnName": "chatRoomId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "coordinatorPublicKey",
+ "columnName": "coordinatorPublicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userPublicKey",
+ "columnName": "userPublicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "threshold",
+ "columnName": "threshold",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "participantCount",
+ "columnName": "participantCount",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "participantPublicKeys",
+ "columnName": "participantPublicKeys",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "subject",
+ "columnName": "subject",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "stage",
+ "columnName": "stage",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hostPublicKey",
+ "columnName": "hostPublicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "round1Random",
+ "columnName": "round1Random",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "round2AuxRandom",
+ "columnName": "round2AuxRandom",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "coordinatorRound1",
+ "columnName": "coordinatorRound1",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "certificate",
+ "columnName": "certificate",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "thresholdPublicKey",
+ "columnName": "thresholdPublicKey",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "secretShare",
+ "columnName": "secretShare",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "publicShares",
+ "columnName": "publicShares",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "recoveryData",
+ "columnName": "recoveryData",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "failureReason",
+ "columnName": "failureReason",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "hostKeyApprovedAt",
+ "columnName": "hostKeyApprovedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "round1ApprovedAt",
+ "columnName": "round1ApprovedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "round2ApprovedAt",
+ "columnName": "round2ApprovedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "approvalRequestedThrough",
+ "columnName": "approvalRequestedThrough",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "parentChatRoomId",
+ "columnName": "parentChatRoomId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "savedAt",
+ "columnName": "savedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_DkgSession_chatRoomId",
+ "unique": false,
+ "columnNames": [
+ "chatRoomId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_DkgSession_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "ChatRoom",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "chatRoomId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "FrostSignerMessage",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `signerPublicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `payload` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`sessionId`, `signerPublicKey`, `kind`), FOREIGN KEY(`sessionId`) REFERENCES `FrostSigningSession`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "sessionId",
+ "columnName": "sessionId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "signerPublicKey",
+ "columnName": "signerPublicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "kind",
+ "columnName": "kind",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "payload",
+ "columnName": "payload",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "sessionId",
+ "signerPublicKey",
+ "kind"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_FrostSignerMessage_sessionId",
+ "unique": false,
+ "columnNames": [
+ "sessionId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSignerMessage_sessionId` ON `${TABLE_NAME}` (`sessionId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "FrostSigningSession",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "sessionId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "FrostSigningItem",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `itemIndex` INTEGER NOT NULL, `unsignedEventJson` TEXT NOT NULL, `eventId` TEXT NOT NULL, `nonceRandom` TEXT NOT NULL, `aggregatedNonce` TEXT, `signature` TEXT, PRIMARY KEY(`sessionId`, `itemIndex`), FOREIGN KEY(`sessionId`) REFERENCES `FrostSigningSession`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "sessionId",
+ "columnName": "sessionId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemIndex",
+ "columnName": "itemIndex",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "unsignedEventJson",
+ "columnName": "unsignedEventJson",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "eventId",
+ "columnName": "eventId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "nonceRandom",
+ "columnName": "nonceRandom",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "aggregatedNonce",
+ "columnName": "aggregatedNonce",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "signature",
+ "columnName": "signature",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "sessionId",
+ "itemIndex"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_FrostSigningItem_sessionId",
+ "unique": false,
+ "columnNames": [
+ "sessionId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSigningItem_sessionId` ON `${TABLE_NAME}` (`sessionId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "FrostSigningSession",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "sessionId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "FrostSigningSession",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `coordinatorPublicKey` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `dkgSessionId` TEXT NOT NULL, `threshold` INTEGER NOT NULL, `participantCount` INTEGER NOT NULL, `signerId` INTEGER NOT NULL, `derivationPath` TEXT, `stage` TEXT NOT NULL, `signerIds` TEXT, `failureReason` TEXT, `signApprovedAt` INTEGER, `approvalRequestedAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chatRoomId",
+ "columnName": "chatRoomId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "coordinatorPublicKey",
+ "columnName": "coordinatorPublicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userPublicKey",
+ "columnName": "userPublicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dkgSessionId",
+ "columnName": "dkgSessionId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "threshold",
+ "columnName": "threshold",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "participantCount",
+ "columnName": "participantCount",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "signerId",
+ "columnName": "signerId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "derivationPath",
+ "columnName": "derivationPath",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "stage",
+ "columnName": "stage",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "signerIds",
+ "columnName": "signerIds",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "failureReason",
+ "columnName": "failureReason",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "signApprovedAt",
+ "columnName": "signApprovedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "approvalRequestedAt",
+ "columnName": "approvalRequestedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "savedAt",
+ "columnName": "savedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_FrostSigningSession_chatRoomId",
+ "unique": false,
+ "columnNames": [
+ "chatRoomId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSigningSession_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)"
+ },
+ {
+ "name": "index_FrostSigningSession_dkgSessionId",
+ "unique": false,
+ "columnNames": [
+ "dkgSessionId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSigningSession_dkgSessionId` ON `${TABLE_NAME}` (`dkgSessionId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "ChatRoom",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "chatRoomId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "GiftWrapMessage",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `receiverPublicKey` TEXT NOT NULL, `receiverRelayHit` TEXT, `content` TEXT NOT NULL, `signature` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publicKey",
+ "columnName": "publicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "kind",
+ "columnName": "kind",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "receiverPublicKey",
+ "columnName": "receiverPublicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "receiverRelayHit",
+ "columnName": "receiverRelayHit",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "content",
+ "columnName": "content",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "signature",
+ "columnName": "signature",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "nostrEventId",
+ "columnName": "nostrEventId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "savedAt",
+ "columnName": "savedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "viewedAt",
+ "columnName": "viewedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "deletedAt",
+ "columnName": "deletedAt",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "NostrEvent",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "nostrEventId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "GiftWrapSeal",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `signature` TEXT NOT NULL, `giftWrapMessageId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`giftWrapMessageId`) REFERENCES `GiftWrapMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publicKey",
+ "columnName": "publicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "kind",
+ "columnName": "kind",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "tags",
+ "columnName": "tags",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "content",
+ "columnName": "content",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "signature",
+ "columnName": "signature",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "giftWrapMessageId",
+ "columnName": "giftWrapMessageId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "savedAt",
+ "columnName": "savedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "viewedAt",
+ "columnName": "viewedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "deletedAt",
+ "columnName": "deletedAt",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "GiftWrapMessage",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "giftWrapMessageId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "GiftWrapPayload",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `quotedEventId` TEXT, `giftWrapSealId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`giftWrapSealId`) REFERENCES `GiftWrapSeal`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publicKey",
+ "columnName": "publicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chatRoomId",
+ "columnName": "chatRoomId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "kind",
+ "columnName": "kind",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "tags",
+ "columnName": "tags",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "content",
+ "columnName": "content",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "quotedEventId",
+ "columnName": "quotedEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "giftWrapSealId",
+ "columnName": "giftWrapSealId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "savedAt",
+ "columnName": "savedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "viewedAt",
+ "columnName": "viewedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "deletedAt",
+ "columnName": "deletedAt",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "GiftWrapSeal",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "giftWrapSealId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "GroupKeyState",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatRoomId` TEXT NOT NULL, `dkgSessionId` TEXT NOT NULL, `thresholdPublicKey` TEXT NOT NULL, `derivationPath` TEXT NOT NULL, `announcedBy` TEXT NOT NULL, `announcedAt` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`chatRoomId`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "chatRoomId",
+ "columnName": "chatRoomId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dkgSessionId",
+ "columnName": "dkgSessionId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "thresholdPublicKey",
+ "columnName": "thresholdPublicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "derivationPath",
+ "columnName": "derivationPath",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "announcedBy",
+ "columnName": "announcedBy",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "announcedAt",
+ "columnName": "announcedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "savedAt",
+ "columnName": "savedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "chatRoomId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_GroupKeyState_dkgSessionId",
+ "unique": false,
+ "columnNames": [
+ "dkgSessionId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupKeyState_dkgSessionId` ON `${TABLE_NAME}` (`dkgSessionId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "ChatRoom",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "chatRoomId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "GroupKeyStateParent",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatRoomId` TEXT NOT NULL, `parentChatRoomId` TEXT NOT NULL, `birthCertificateJson` TEXT NOT NULL, PRIMARY KEY(`chatRoomId`, `parentChatRoomId`), FOREIGN KEY(`chatRoomId`) REFERENCES `GroupKeyState`(`chatRoomId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "chatRoomId",
+ "columnName": "chatRoomId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "parentChatRoomId",
+ "columnName": "parentChatRoomId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "birthCertificateJson",
+ "columnName": "birthCertificateJson",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "chatRoomId",
+ "parentChatRoomId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_GroupKeyStateParent_parentChatRoomId",
+ "unique": false,
+ "columnNames": [
+ "parentChatRoomId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupKeyStateParent_parentChatRoomId` ON `${TABLE_NAME}` (`parentChatRoomId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "GroupKeyState",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "chatRoomId"
+ ],
+ "referencedColumns": [
+ "chatRoomId"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "GroupSignedEvent",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `signature` TEXT NOT NULL, `derivationPath` TEXT, `frostSigningSessionId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chatRoomId",
+ "columnName": "chatRoomId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publicKey",
+ "columnName": "publicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "kind",
+ "columnName": "kind",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "tags",
+ "columnName": "tags",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "content",
+ "columnName": "content",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "signature",
+ "columnName": "signature",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "derivationPath",
+ "columnName": "derivationPath",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "frostSigningSessionId",
+ "columnName": "frostSigningSessionId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "savedAt",
+ "columnName": "savedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_GroupSignedEvent_chatRoomId",
+ "unique": false,
+ "columnNames": [
+ "chatRoomId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupSignedEvent_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)"
+ },
+ {
+ "name": "index_GroupSignedEvent_kind",
+ "unique": false,
+ "columnNames": [
+ "kind"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupSignedEvent_kind` ON `${TABLE_NAME}` (`kind`)"
+ },
+ {
+ "name": "index_GroupSignedEvent_frostSigningSessionId",
+ "unique": false,
+ "columnNames": [
+ "frostSigningSessionId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupSignedEvent_frostSigningSessionId` ON `${TABLE_NAME}` (`frostSigningSessionId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "ChatRoom",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "chatRoomId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "InReplyToRelation",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`replyingNostrEventId` TEXT NOT NULL, `inReplyToNostrEventId` TEXT NOT NULL, `inReplyToProfilePublicKey` TEXT, `inReplyToRootNostrEventId` TEXT, `inReplyToRootProfilePublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`replyingNostrEventId`, `inReplyToNostrEventId`), FOREIGN KEY(`inReplyToProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`inReplyToRootProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`replyingNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`inReplyToNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`inReplyToRootNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "replyingNostrEventId",
+ "columnName": "replyingNostrEventId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "inReplyToNostrEventId",
+ "columnName": "inReplyToNostrEventId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "inReplyToProfilePublicKey",
+ "columnName": "inReplyToProfilePublicKey",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "inReplyToRootNostrEventId",
+ "columnName": "inReplyToRootNostrEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "inReplyToRootProfilePublicKey",
+ "columnName": "inReplyToRootProfilePublicKey",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "savedAt",
+ "columnName": "savedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "viewedAt",
+ "columnName": "viewedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "deletedAt",
+ "columnName": "deletedAt",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "replyingNostrEventId",
+ "inReplyToNostrEventId"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "Profile",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "inReplyToProfilePublicKey"
+ ],
+ "referencedColumns": [
+ "publicKey"
+ ]
+ },
+ {
+ "table": "Profile",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "inReplyToRootProfilePublicKey"
+ ],
+ "referencedColumns": [
+ "publicKey"
+ ]
+ },
+ {
+ "table": "NostrEvent",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "replyingNostrEventId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "NostrEvent",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "inReplyToNostrEventId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "NostrEvent",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "inReplyToRootNostrEventId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "MantraArtifact",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `visibility` TEXT NOT NULL, `dialectId` TEXT NOT NULL, `license` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`dialectId`) REFERENCES `MantraDialect`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publicKey",
+ "columnName": "publicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "url",
+ "columnName": "url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "visibility",
+ "columnName": "visibility",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dialectId",
+ "columnName": "dialectId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "license",
+ "columnName": "license",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chatRoomId",
+ "columnName": "chatRoomId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "signature",
+ "columnName": "signature",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "relayHint",
+ "columnName": "relayHint",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "marmotGroupEventId",
+ "columnName": "marmotGroupEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "groupSignedEventId",
+ "columnName": "groupSignedEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "nostrEventId",
+ "columnName": "nostrEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "MantraDialect",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "dialectId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "ChatRoom",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "chatRoomId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "MantraArtifactVersion",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `artifactId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `versionLabel` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`artifactId`) REFERENCES `MantraArtifact`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artifactId",
+ "columnName": "artifactId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publicKey",
+ "columnName": "publicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "versionLabel",
+ "columnName": "versionLabel",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "signature",
+ "columnName": "signature",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chatRoomId",
+ "columnName": "chatRoomId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "relayHint",
+ "columnName": "relayHint",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "marmotGroupEventId",
+ "columnName": "marmotGroupEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "groupSignedEventId",
+ "columnName": "groupSignedEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "nostrEventId",
+ "columnName": "nostrEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "MantraArtifact",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "artifactId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "ChatRoom",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "chatRoomId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "MantraChapter",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `artifactVersionId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `originalText` TEXT NOT NULL, `index` INTEGER NOT NULL, `wordCount` INTEGER NOT NULL, `characterCount` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`artifactVersionId`) REFERENCES `MantraArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artifactVersionId",
+ "columnName": "artifactVersionId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publicKey",
+ "columnName": "publicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "originalText",
+ "columnName": "originalText",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "index",
+ "columnName": "index",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "wordCount",
+ "columnName": "wordCount",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "characterCount",
+ "columnName": "characterCount",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "signature",
+ "columnName": "signature",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chatRoomId",
+ "columnName": "chatRoomId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "relayHint",
+ "columnName": "relayHint",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "marmotGroupEventId",
+ "columnName": "marmotGroupEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "groupSignedEventId",
+ "columnName": "groupSignedEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "nostrEventId",
+ "columnName": "nostrEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "MantraArtifactVersion",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "artifactVersionId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "ChatRoom",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "chatRoomId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "MantraChunk",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chapterId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `text` TEXT NOT NULL, `index` INTEGER NOT NULL, `wordCount` INTEGER NOT NULL, `characterCount` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chapterId`) REFERENCES `MantraChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chapterId",
+ "columnName": "chapterId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publicKey",
+ "columnName": "publicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "text",
+ "columnName": "text",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "index",
+ "columnName": "index",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "wordCount",
+ "columnName": "wordCount",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "characterCount",
+ "columnName": "characterCount",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "signature",
+ "columnName": "signature",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chatRoomId",
+ "columnName": "chatRoomId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "relayHint",
+ "columnName": "relayHint",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "marmotGroupEventId",
+ "columnName": "marmotGroupEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "groupSignedEventId",
+ "columnName": "groupSignedEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "nostrEventId",
+ "columnName": "nostrEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "MantraChapter",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "chapterId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "ChatRoom",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "chatRoomId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "MantraDialect",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `country` TEXT NOT NULL, `language` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publicKey",
+ "columnName": "publicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "country",
+ "columnName": "country",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "language",
+ "columnName": "language",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "signature",
+ "columnName": "signature",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chatRoomId",
+ "columnName": "chatRoomId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "relayHint",
+ "columnName": "relayHint",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "marmotGroupEventId",
+ "columnName": "marmotGroupEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "groupSignedEventId",
+ "columnName": "groupSignedEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "nostrEventId",
+ "columnName": "nostrEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "ChatRoom",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "chatRoomId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "MantraProject",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `creatorPublicKey` TEXT NOT NULL, `ownerPublicKeys` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publicKey",
+ "columnName": "publicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "description",
+ "columnName": "description",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "creatorPublicKey",
+ "columnName": "creatorPublicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "ownerPublicKeys",
+ "columnName": "ownerPublicKeys",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chatRoomId",
+ "columnName": "chatRoomId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "signature",
+ "columnName": "signature",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "relayHint",
+ "columnName": "relayHint",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "marmotGroupEventId",
+ "columnName": "marmotGroupEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "groupSignedEventId",
+ "columnName": "groupSignedEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "nostrEventId",
+ "columnName": "nostrEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_MantraProject_chatRoomId",
+ "unique": false,
+ "columnNames": [
+ "chatRoomId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProject_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "ChatRoom",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "chatRoomId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "MantraProjectArtifactVersion",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `projectId` TEXT NOT NULL, `artifactVersionId` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`projectId`) REFERENCES `MantraProject`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artifactVersionId`) REFERENCES `MantraArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publicKey",
+ "columnName": "publicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "projectId",
+ "columnName": "projectId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artifactVersionId",
+ "columnName": "artifactVersionId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chatRoomId",
+ "columnName": "chatRoomId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "signature",
+ "columnName": "signature",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "relayHint",
+ "columnName": "relayHint",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "marmotGroupEventId",
+ "columnName": "marmotGroupEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "groupSignedEventId",
+ "columnName": "groupSignedEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "nostrEventId",
+ "columnName": "nostrEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_MantraProjectArtifactVersion_projectId",
+ "unique": false,
+ "columnNames": [
+ "projectId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectArtifactVersion_projectId` ON `${TABLE_NAME}` (`projectId`)"
+ },
+ {
+ "name": "index_MantraProjectArtifactVersion_artifactVersionId",
+ "unique": false,
+ "columnNames": [
+ "artifactVersionId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectArtifactVersion_artifactVersionId` ON `${TABLE_NAME}` (`artifactVersionId`)"
+ },
+ {
+ "name": "index_MantraProjectArtifactVersion_chatRoomId",
+ "unique": false,
+ "columnNames": [
+ "chatRoomId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectArtifactVersion_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "MantraProject",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "projectId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "MantraArtifactVersion",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "artifactVersionId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "ChatRoom",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "chatRoomId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "MantraProjectTranslationArtifactVersion",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `projectId` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`projectId`) REFERENCES `MantraProject`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publicKey",
+ "columnName": "publicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "projectId",
+ "columnName": "projectId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "translationArtifactVersionId",
+ "columnName": "translationArtifactVersionId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chatRoomId",
+ "columnName": "chatRoomId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "signature",
+ "columnName": "signature",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "relayHint",
+ "columnName": "relayHint",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "marmotGroupEventId",
+ "columnName": "marmotGroupEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "groupSignedEventId",
+ "columnName": "groupSignedEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "nostrEventId",
+ "columnName": "nostrEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_MantraProjectTranslationArtifactVersion_projectId",
+ "unique": false,
+ "columnNames": [
+ "projectId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectTranslationArtifactVersion_projectId` ON `${TABLE_NAME}` (`projectId`)"
+ },
+ {
+ "name": "index_MantraProjectTranslationArtifactVersion_translationArtifactVersionId",
+ "unique": false,
+ "columnNames": [
+ "translationArtifactVersionId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectTranslationArtifactVersion_translationArtifactVersionId` ON `${TABLE_NAME}` (`translationArtifactVersionId`)"
+ },
+ {
+ "name": "index_MantraProjectTranslationArtifactVersion_chatRoomId",
+ "unique": false,
+ "columnNames": [
+ "chatRoomId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectTranslationArtifactVersion_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "MantraProject",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "projectId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "MantraTranslationArtifactVersion",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "translationArtifactVersionId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "ChatRoom",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "chatRoomId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "MantraTranslation",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationChunkId` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `text` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationChunkId`) REFERENCES `MantraTranslationChunk`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publicKey",
+ "columnName": "publicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "translationChunkId",
+ "columnName": "translationChunkId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "translationArtifactVersionId",
+ "columnName": "translationArtifactVersionId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "text",
+ "columnName": "text",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "signature",
+ "columnName": "signature",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chatRoomId",
+ "columnName": "chatRoomId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "relayHint",
+ "columnName": "relayHint",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "marmotGroupEventId",
+ "columnName": "marmotGroupEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "groupSignedEventId",
+ "columnName": "groupSignedEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "nostrEventId",
+ "columnName": "nostrEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "MantraTranslationChunk",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "translationChunkId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "MantraTranslationArtifactVersion",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "translationArtifactVersionId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "ChatRoom",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "chatRoomId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "MantraTranslationArtifactVersion",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `artifactVersionId` TEXT NOT NULL, `dialectId` TEXT NOT NULL, `name` TEXT NOT NULL, `visibility` TEXT NOT NULL, `license` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`artifactVersionId`) REFERENCES `MantraArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`dialectId`) REFERENCES `MantraDialect`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publicKey",
+ "columnName": "publicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artifactVersionId",
+ "columnName": "artifactVersionId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dialectId",
+ "columnName": "dialectId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "visibility",
+ "columnName": "visibility",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "license",
+ "columnName": "license",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "signature",
+ "columnName": "signature",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chatRoomId",
+ "columnName": "chatRoomId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "relayHint",
+ "columnName": "relayHint",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "marmotGroupEventId",
+ "columnName": "marmotGroupEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "groupSignedEventId",
+ "columnName": "groupSignedEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "nostrEventId",
+ "columnName": "nostrEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "MantraArtifactVersion",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "artifactVersionId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "MantraDialect",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "dialectId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "ChatRoom",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "chatRoomId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "MantraTranslationArtifactVersionContributor",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `editorPublicKey` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersionContributor`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publicKey",
+ "columnName": "publicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "translationArtifactVersionId",
+ "columnName": "translationArtifactVersionId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "editorPublicKey",
+ "columnName": "editorPublicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "signature",
+ "columnName": "signature",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chatRoomId",
+ "columnName": "chatRoomId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "relayHint",
+ "columnName": "relayHint",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "marmotGroupEventId",
+ "columnName": "marmotGroupEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "groupSignedEventId",
+ "columnName": "groupSignedEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "nostrEventId",
+ "columnName": "nostrEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "MantraTranslationArtifactVersionContributor",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "translationArtifactVersionId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "ChatRoom",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "chatRoomId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "MantraTranslationChapter",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chapterId` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `index` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chapterId`) REFERENCES `MantraChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publicKey",
+ "columnName": "publicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chapterId",
+ "columnName": "chapterId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "translationArtifactVersionId",
+ "columnName": "translationArtifactVersionId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "index",
+ "columnName": "index",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "signature",
+ "columnName": "signature",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chatRoomId",
+ "columnName": "chatRoomId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "relayHint",
+ "columnName": "relayHint",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "marmotGroupEventId",
+ "columnName": "marmotGroupEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "groupSignedEventId",
+ "columnName": "groupSignedEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "nostrEventId",
+ "columnName": "nostrEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "MantraTranslationArtifactVersion",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "translationArtifactVersionId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "MantraChapter",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "chapterId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "ChatRoom",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "chatRoomId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "MantraTranslationChapterContributor",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationChapterId` TEXT NOT NULL, `translatorPublicKey` TEXT NOT NULL, `role` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationChapterId`) REFERENCES `MantraTranslationChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publicKey",
+ "columnName": "publicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "translationChapterId",
+ "columnName": "translationChapterId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "translatorPublicKey",
+ "columnName": "translatorPublicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "role",
+ "columnName": "role",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "signature",
+ "columnName": "signature",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chatRoomId",
+ "columnName": "chatRoomId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "relayHint",
+ "columnName": "relayHint",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "marmotGroupEventId",
+ "columnName": "marmotGroupEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "groupSignedEventId",
+ "columnName": "groupSignedEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "nostrEventId",
+ "columnName": "nostrEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "MantraTranslationChapter",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "translationChapterId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "ChatRoom",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "chatRoomId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "MantraTranslationChunk",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chunkId` TEXT NOT NULL, `translationChapterId` TEXT NOT NULL, `text` TEXT NOT NULL, `index` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chunkId`) REFERENCES `MantraChunk`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`translationChapterId`) REFERENCES `MantraTranslationChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publicKey",
+ "columnName": "publicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chunkId",
+ "columnName": "chunkId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "translationChapterId",
+ "columnName": "translationChapterId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "text",
+ "columnName": "text",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "index",
+ "columnName": "index",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "signature",
+ "columnName": "signature",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chatRoomId",
+ "columnName": "chatRoomId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "relayHint",
+ "columnName": "relayHint",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "marmotGroupEventId",
+ "columnName": "marmotGroupEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "groupSignedEventId",
+ "columnName": "groupSignedEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "nostrEventId",
+ "columnName": "nostrEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "MantraChunk",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "chunkId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "MantraTranslationChapter",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "translationChapterId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "ChatRoom",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "chatRoomId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "MantraTranslationContributor",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `dTag` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationId` TEXT NOT NULL, `translatorPublicKey` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationId`) REFERENCES `MantraTranslation`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dTag",
+ "columnName": "dTag",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publicKey",
+ "columnName": "publicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "translationId",
+ "columnName": "translationId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "translatorPublicKey",
+ "columnName": "translatorPublicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "signature",
+ "columnName": "signature",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chatRoomId",
+ "columnName": "chatRoomId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "relayHint",
+ "columnName": "relayHint",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "marmotGroupEventId",
+ "columnName": "marmotGroupEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "groupSignedEventId",
+ "columnName": "groupSignedEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "nostrEventId",
+ "columnName": "nostrEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "MantraTranslation",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "translationId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "ChatRoom",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "chatRoomId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "MarmotCommitResult",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `peerKeyPackageEventId` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `isOneMemberInitialGroupCreation` INTEGER NOT NULL, `commitBytes` BLOB NOT NULL, `welcomeBytes` BLOB, `groupInfoBytes` BLOB, `framedCommitBytes` BLOB NOT NULL, `preCommitExporterSecret` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userPublicKey",
+ "columnName": "userPublicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "peerKeyPackageEventId",
+ "columnName": "peerKeyPackageEventId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chatRoomId",
+ "columnName": "chatRoomId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isOneMemberInitialGroupCreation",
+ "columnName": "isOneMemberInitialGroupCreation",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "commitBytes",
+ "columnName": "commitBytes",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "welcomeBytes",
+ "columnName": "welcomeBytes",
+ "affinity": "BLOB"
+ },
+ {
+ "fieldPath": "groupInfoBytes",
+ "columnName": "groupInfoBytes",
+ "affinity": "BLOB"
+ },
+ {
+ "fieldPath": "framedCommitBytes",
+ "columnName": "framedCommitBytes",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "preCommitExporterSecret",
+ "columnName": "preCommitExporterSecret",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "savedAt",
+ "columnName": "savedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "viewedAt",
+ "columnName": "viewedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "deletedAt",
+ "columnName": "deletedAt",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "MarmotGroupEvent",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `encryptedContent` TEXT NOT NULL, `expiresAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userPublicKey",
+ "columnName": "userPublicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publicKey",
+ "columnName": "publicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chatRoomId",
+ "columnName": "chatRoomId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "signature",
+ "columnName": "signature",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "encryptedContent",
+ "columnName": "encryptedContent",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "expiresAt",
+ "columnName": "expiresAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "savedAt",
+ "columnName": "savedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deletedAt",
+ "columnName": "deletedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "broadcastedAt",
+ "columnName": "broadcastedAt",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "NostrEvent",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "MarmotInnerEvent",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `quotedEventId` TEXT, `payloadEventId` TEXT, `marmotGroupEventId` TEXT, `directMessageRecipientPublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`marmotGroupEventId`) REFERENCES `MarmotGroupEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publicKey",
+ "columnName": "publicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chatRoomId",
+ "columnName": "chatRoomId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "kind",
+ "columnName": "kind",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "tags",
+ "columnName": "tags",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "content",
+ "columnName": "content",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "quotedEventId",
+ "columnName": "quotedEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "payloadEventId",
+ "columnName": "payloadEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "marmotGroupEventId",
+ "columnName": "marmotGroupEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "directMessageRecipientPublicKey",
+ "columnName": "directMessageRecipientPublicKey",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "savedAt",
+ "columnName": "savedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "viewedAt",
+ "columnName": "viewedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "deletedAt",
+ "columnName": "deletedAt",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "MarmotGroupEvent",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "marmotGroupEventId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "MarmotKeyPackage",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `tlsEncodedMarmotKeyPackage` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`publicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publicKey",
+ "columnName": "publicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "tlsEncodedMarmotKeyPackage",
+ "columnName": "tlsEncodedMarmotKeyPackage",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "savedAt",
+ "columnName": "savedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deletedAt",
+ "columnName": "deletedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "broadcastedAt",
+ "columnName": "broadcastedAt",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "Profile",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "publicKey"
+ ],
+ "referencedColumns": [
+ "publicKey"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "MarmotKeyPackageBundle",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `tlsEncodedMarmotKeyPackage` BLOB NOT NULL, `ncryptsecInitPrivateKey` TEXT NOT NULL, `ncryptsecEncryptionPrivateKey` TEXT NOT NULL, `ncryptsecSignaturePrivateKey` TEXT NOT NULL, `consumed` INTEGER NOT NULL, `rotated` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publicKey",
+ "columnName": "publicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "tlsEncodedMarmotKeyPackage",
+ "columnName": "tlsEncodedMarmotKeyPackage",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "ncryptsecInitPrivateKey",
+ "columnName": "ncryptsecInitPrivateKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "ncryptsecEncryptionPrivateKey",
+ "columnName": "ncryptsecEncryptionPrivateKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "ncryptsecSignaturePrivateKey",
+ "columnName": "ncryptsecSignaturePrivateKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "consumed",
+ "columnName": "consumed",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "rotated",
+ "columnName": "rotated",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "savedAt",
+ "columnName": "savedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deletedAt",
+ "columnName": "deletedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "broadcastedAt",
+ "columnName": "broadcastedAt",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_MarmotKeyPackageBundle_publicKey",
+ "unique": false,
+ "columnNames": [
+ "publicKey"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_MarmotKeyPackageBundle_publicKey` ON `${TABLE_NAME}` (`publicKey`)"
+ }
+ ]
+ },
+ {
+ "tableName": "MarmotRetainedEpochSecret",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatRoomId` TEXT NOT NULL, `epoch` INTEGER NOT NULL, `senderDataSecret` BLOB NOT NULL, `encryptionSecret` BLOB NOT NULL, `leafCount` INTEGER NOT NULL, `exporterSecret` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`chatRoomId`, `epoch`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "chatRoomId",
+ "columnName": "chatRoomId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "epoch",
+ "columnName": "epoch",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "senderDataSecret",
+ "columnName": "senderDataSecret",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "encryptionSecret",
+ "columnName": "encryptionSecret",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "leafCount",
+ "columnName": "leafCount",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "exporterSecret",
+ "columnName": "exporterSecret",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "savedAt",
+ "columnName": "savedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "chatRoomId",
+ "epoch"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "ChatRoom",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "chatRoomId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "Mention",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mentionedPublicKey` TEXT NOT NULL, `mentioningNostrEventId` TEXT, `relayUrl` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, FOREIGN KEY(`mentionedPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`mentioningNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mentionedPublicKey",
+ "columnName": "mentionedPublicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mentioningNostrEventId",
+ "columnName": "mentioningNostrEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "relayUrl",
+ "columnName": "relayUrl",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "savedAt",
+ "columnName": "savedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deletedAt",
+ "columnName": "deletedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "broadcastedAt",
+ "columnName": "broadcastedAt",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "Profile",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "mentionedPublicKey"
+ ],
+ "referencedColumns": [
+ "publicKey"
+ ]
+ },
+ {
+ "table": "NostrEvent",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "mentioningNostrEventId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "NegentropySynchronizeRequest",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `uuid` TEXT NOT NULL, `purpose` TEXT NOT NULL, `status` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `isRecommendedRelay` INTEGER NOT NULL, `level` INTEGER NOT NULL, `synchronizationFilter` TEXT NOT NULL, `ownerPublicKey` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "purpose",
+ "columnName": "purpose",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "relayURL",
+ "columnName": "relayURL",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isRecommendedRelay",
+ "columnName": "isRecommendedRelay",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "level",
+ "columnName": "level",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "synchronizationFilter",
+ "columnName": "synchronizationFilter",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "ownerPublicKey",
+ "columnName": "ownerPublicKey",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "nostrEventId",
+ "columnName": "nostrEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_NegentropySynchronizeRequest_nostrEventId",
+ "unique": false,
+ "columnNames": [
+ "nostrEventId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_NegentropySynchronizeRequest_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)"
+ },
+ {
+ "name": "index_NegentropySynchronizeRequest_status",
+ "unique": false,
+ "columnNames": [
+ "status"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_NegentropySynchronizeRequest_status` ON `${TABLE_NAME}` (`status`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "NostrEvent",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "nostrEventId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "NegentropySynchronizeResult",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `status` TEXT NOT NULL, `negentropySynchronizeRequestId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`negentropySynchronizeRequestId`) REFERENCES `SynchronizeNostrEventResult`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "relayURL",
+ "columnName": "relayURL",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "negentropySynchronizeRequestId",
+ "columnName": "negentropySynchronizeRequestId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "SynchronizeNostrEventResult",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "negentropySynchronizeRequestId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "NostrEvent",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `pubKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `sig` TEXT NOT NULL, `relayUrl` TEXT, `quotedNostrEventId` TEXT, `quotedAuthorPublicKey` TEXT, `inReplyToNostrEventId` TEXT, `inReplyToAuthorPublicKey` TEXT, `inReplyToRootNostrEventId` TEXT, `inReplyToRootAuthorPublicKey` TEXT, `repostedNostrEventId` TEXT, `repostedAuthorPublicKey` TEXT, `unsignedNostrEventId` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "pubKey",
+ "columnName": "pubKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "kind",
+ "columnName": "kind",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "tags",
+ "columnName": "tags",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "content",
+ "columnName": "content",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sig",
+ "columnName": "sig",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "relayUrl",
+ "columnName": "relayUrl",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "quotedNostrEventId",
+ "columnName": "quotedNostrEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "quotedAuthorPublicKey",
+ "columnName": "quotedAuthorPublicKey",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "inReplyToNostrEventId",
+ "columnName": "inReplyToNostrEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "inReplyToAuthorPublicKey",
+ "columnName": "inReplyToAuthorPublicKey",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "inReplyToRootNostrEventId",
+ "columnName": "inReplyToRootNostrEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "inReplyToRootAuthorPublicKey",
+ "columnName": "inReplyToRootAuthorPublicKey",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "repostedNostrEventId",
+ "columnName": "repostedNostrEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "repostedAuthorPublicKey",
+ "columnName": "repostedAuthorPublicKey",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "unsignedNostrEventId",
+ "columnName": "unsignedNostrEventId",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "savedAt",
+ "columnName": "savedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deletedAt",
+ "columnName": "deletedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "broadcastedAt",
+ "columnName": "broadcastedAt",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_NostrEvent_kind",
+ "unique": false,
+ "columnNames": [
+ "kind"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_kind` ON `${TABLE_NAME}` (`kind`)"
+ },
+ {
+ "name": "index_NostrEvent_pubKey",
+ "unique": false,
+ "columnNames": [
+ "pubKey"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_pubKey` ON `${TABLE_NAME}` (`pubKey`)"
+ },
+ {
+ "name": "index_NostrEvent_quotedNostrEventId",
+ "unique": false,
+ "columnNames": [
+ "quotedNostrEventId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_quotedNostrEventId` ON `${TABLE_NAME}` (`quotedNostrEventId`)"
+ },
+ {
+ "name": "index_NostrEvent_inReplyToNostrEventId",
+ "unique": false,
+ "columnNames": [
+ "inReplyToNostrEventId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_inReplyToNostrEventId` ON `${TABLE_NAME}` (`inReplyToNostrEventId`)"
+ },
+ {
+ "name": "index_NostrEvent_inReplyToRootNostrEventId",
+ "unique": false,
+ "columnNames": [
+ "inReplyToRootNostrEventId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_inReplyToRootNostrEventId` ON `${TABLE_NAME}` (`inReplyToRootNostrEventId`)"
+ }
+ ]
+ },
+ {
+ "tableName": "NostrEventRelay",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`relayURL` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`nostrEventId`, `relayURL`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "relayURL",
+ "columnName": "relayURL",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "nostrEventId",
+ "columnName": "nostrEventId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "nostrEventId",
+ "relayURL"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_NostrEventRelay_relayURL",
+ "unique": false,
+ "columnNames": [
+ "relayURL"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEventRelay_relayURL` ON `${TABLE_NAME}` (`relayURL`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "NostrEvent",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "nostrEventId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "Participant",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `participantPublicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `adminAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, FOREIGN KEY(`participantPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "participantPublicKey",
+ "columnName": "participantPublicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chatRoomId",
+ "columnName": "chatRoomId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "relayHint",
+ "columnName": "relayHint",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "adminAt",
+ "columnName": "adminAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "savedAt",
+ "columnName": "savedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deletedAt",
+ "columnName": "deletedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "broadcastedAt",
+ "columnName": "broadcastedAt",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "Profile",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "participantPublicKey"
+ ],
+ "referencedColumns": [
+ "publicKey"
+ ]
+ },
+ {
+ "table": "ChatRoom",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "chatRoomId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "Post",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `repostId` TEXT, `quote` TEXT, `content` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `replyToId` TEXT, `profilePublicKey` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`profilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`replyToId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "repostId",
+ "columnName": "repostId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "quote",
+ "columnName": "quote",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "content",
+ "columnName": "content",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "nostrEventId",
+ "columnName": "nostrEventId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "replyToId",
+ "columnName": "replyToId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "profilePublicKey",
+ "columnName": "profilePublicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "savedAt",
+ "columnName": "savedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "viewedAt",
+ "columnName": "viewedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "deletedAt",
+ "columnName": "deletedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "broadcastedAt",
+ "columnName": "broadcastedAt",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_Post_profilePublicKey",
+ "unique": false,
+ "columnNames": [
+ "profilePublicKey"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_profilePublicKey` ON `${TABLE_NAME}` (`profilePublicKey`)"
+ },
+ {
+ "name": "index_Post_nostrEventId",
+ "unique": false,
+ "columnNames": [
+ "nostrEventId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)"
+ },
+ {
+ "name": "index_Post_replyToId",
+ "unique": false,
+ "columnNames": [
+ "replyToId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_replyToId` ON `${TABLE_NAME}` (`replyToId`)"
+ },
+ {
+ "name": "index_Post_repostId",
+ "unique": false,
+ "columnNames": [
+ "repostId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_repostId` ON `${TABLE_NAME}` (`repostId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "Profile",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "profilePublicKey"
+ ],
+ "referencedColumns": [
+ "publicKey"
+ ]
+ },
+ {
+ "table": "NostrEvent",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "nostrEventId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "Post",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "replyToId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "Post",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "repostId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "Profile",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`publicKey` TEXT NOT NULL, `userName` TEXT, `displayName` TEXT, `picture` TEXT, `banner` TEXT, `website` TEXT, `about` TEXT, `bot` INTEGER, `pronouns` TEXT, `nip05` TEXT, `domain` TEXT, `lud06` TEXT, `lud16` TEXT, `twitter` TEXT, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`publicKey`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "publicKey",
+ "columnName": "publicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userName",
+ "columnName": "userName",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "displayName",
+ "columnName": "displayName",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "picture",
+ "columnName": "picture",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "banner",
+ "columnName": "banner",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "website",
+ "columnName": "website",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "about",
+ "columnName": "about",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "bot",
+ "columnName": "bot",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "pronouns",
+ "columnName": "pronouns",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "nip05",
+ "columnName": "nip05",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "domain",
+ "columnName": "domain",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lud06",
+ "columnName": "lud06",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lud16",
+ "columnName": "lud16",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "twitter",
+ "columnName": "twitter",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "nostrEventId",
+ "columnName": "nostrEventId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "savedAt",
+ "columnName": "savedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "viewedAt",
+ "columnName": "viewedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "deletedAt",
+ "columnName": "deletedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "broadcastedAt",
+ "columnName": "broadcastedAt",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "publicKey"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_Profile_nostrEventId",
+ "unique": false,
+ "columnNames": [
+ "nostrEventId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_Profile_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "NostrEvent",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "nostrEventId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "QuotedRelation",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`quotingNostrEventId` TEXT NOT NULL, `quotedNostrEventId` TEXT NOT NULL, `quotedProfilePublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`quotingNostrEventId`, `quotedNostrEventId`), FOREIGN KEY(`quotedProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`quotingNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`quotedNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "quotingNostrEventId",
+ "columnName": "quotingNostrEventId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "quotedNostrEventId",
+ "columnName": "quotedNostrEventId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "quotedProfilePublicKey",
+ "columnName": "quotedProfilePublicKey",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "savedAt",
+ "columnName": "savedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "viewedAt",
+ "columnName": "viewedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "deletedAt",
+ "columnName": "deletedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "broadcastedAt",
+ "columnName": "broadcastedAt",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "quotingNostrEventId",
+ "quotedNostrEventId"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "Profile",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "quotedProfilePublicKey"
+ ],
+ "referencedColumns": [
+ "publicKey"
+ ]
+ },
+ {
+ "table": "NostrEvent",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "quotingNostrEventId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "NostrEvent",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "quotedNostrEventId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "Reaction",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `content` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `replyToId` TEXT NOT NULL, `profilePublicKey` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`profilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`replyToId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "content",
+ "columnName": "content",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "nostrEventId",
+ "columnName": "nostrEventId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "replyToId",
+ "columnName": "replyToId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "profilePublicKey",
+ "columnName": "profilePublicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "savedAt",
+ "columnName": "savedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "viewedAt",
+ "columnName": "viewedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "deletedAt",
+ "columnName": "deletedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "broadcastedAt",
+ "columnName": "broadcastedAt",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_Reaction_profilePublicKey",
+ "unique": false,
+ "columnNames": [
+ "profilePublicKey"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_Reaction_profilePublicKey` ON `${TABLE_NAME}` (`profilePublicKey`)"
+ },
+ {
+ "name": "index_Reaction_nostrEventId",
+ "unique": false,
+ "columnNames": [
+ "nostrEventId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_Reaction_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)"
+ },
+ {
+ "name": "index_Reaction_replyToId",
+ "unique": false,
+ "columnNames": [
+ "replyToId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_Reaction_replyToId` ON `${TABLE_NAME}` (`replyToId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "Profile",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "profilePublicKey"
+ ],
+ "referencedColumns": [
+ "publicKey"
+ ]
+ },
+ {
+ "table": "NostrEvent",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "nostrEventId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "Post",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "replyToId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "RecentSearch",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`query` TEXT NOT NULL, `synchronizationFilters` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`query`))",
+ "fields": [
+ {
+ "fieldPath": "query",
+ "columnName": "query",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "synchronizationFilters",
+ "columnName": "synchronizationFilters",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "query"
+ ]
+ }
+ },
+ {
+ "tableName": "Relay",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`publicKey` TEXT NOT NULL, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `read` INTEGER NOT NULL, `write` INTEGER NOT NULL, PRIMARY KEY(`publicKey`, `type`, `url`))",
+ "fields": [
+ {
+ "fieldPath": "publicKey",
+ "columnName": "publicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "url",
+ "columnName": "url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "read",
+ "columnName": "read",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "write",
+ "columnName": "write",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "publicKey",
+ "type",
+ "url"
+ ]
+ }
+ },
+ {
+ "tableName": "RepostedRelation",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repostingNostrEventId` TEXT NOT NULL, `repostedNostrEventId` TEXT NOT NULL, `repostedProfilePublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`repostingNostrEventId`, `repostedNostrEventId`), FOREIGN KEY(`repostedProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostedNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostingNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "repostingNostrEventId",
+ "columnName": "repostingNostrEventId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "repostedNostrEventId",
+ "columnName": "repostedNostrEventId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "repostedProfilePublicKey",
+ "columnName": "repostedProfilePublicKey",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "savedAt",
+ "columnName": "savedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "viewedAt",
+ "columnName": "viewedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "deletedAt",
+ "columnName": "deletedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "broadcastedAt",
+ "columnName": "broadcastedAt",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "repostingNostrEventId",
+ "repostedNostrEventId"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "Profile",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "repostedProfilePublicKey"
+ ],
+ "referencedColumns": [
+ "publicKey"
+ ]
+ },
+ {
+ "table": "NostrEvent",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "repostedNostrEventId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "NostrEvent",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "repostingNostrEventId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "SynchronizeNostrEventRequest",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `purpose` TEXT NOT NULL, `status` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `isRecommendedRelay` INTEGER NOT NULL, `level` INTEGER NOT NULL, `synchronizationFilters` TEXT NOT NULL, `ownerPublicKey` TEXT, `nostrEventId` TEXT, `unsignedNostrEventId` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`unsignedNostrEventId`) REFERENCES `UnsignedNostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "purpose",
+ "columnName": "purpose",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "relayURL",
+ "columnName": "relayURL",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isRecommendedRelay",
+ "columnName": "isRecommendedRelay",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "level",
+ "columnName": "level",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "synchronizationFilters",
+ "columnName": "synchronizationFilters",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "ownerPublicKey",
+ "columnName": "ownerPublicKey",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "nostrEventId",
+ "columnName": "nostrEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "unsignedNostrEventId",
+ "columnName": "unsignedNostrEventId",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_SynchronizeNostrEventRequest_nostrEventId",
+ "unique": false,
+ "columnNames": [
+ "nostrEventId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_SynchronizeNostrEventRequest_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)"
+ },
+ {
+ "name": "index_SynchronizeNostrEventRequest_status",
+ "unique": false,
+ "columnNames": [
+ "status"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_SynchronizeNostrEventRequest_status` ON `${TABLE_NAME}` (`status`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "NostrEvent",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "nostrEventId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "UnsignedNostrEvent",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "unsignedNostrEventId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "SynchronizeNostrEventResult",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "relayURL",
+ "columnName": "relayURL",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "nostrEventId",
+ "columnName": "nostrEventId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_SynchronizeNostrEventResult_nostrEventId",
+ "unique": false,
+ "columnNames": [
+ "nostrEventId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_SynchronizeNostrEventResult_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "NostrEvent",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "nostrEventId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "UnsignedNostrEvent",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `pubKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `privateTags` TEXT, `content` TEXT NOT NULL, `signedAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "pubKey",
+ "columnName": "pubKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "kind",
+ "columnName": "kind",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "tags",
+ "columnName": "tags",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "privateTags",
+ "columnName": "privateTags",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "content",
+ "columnName": "content",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "signedAt",
+ "columnName": "signedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "savedAt",
+ "columnName": "savedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deletedAt",
+ "columnName": "deletedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "broadcastedAt",
+ "columnName": "broadcastedAt",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_UnsignedNostrEvent_pubKey",
+ "unique": false,
+ "columnNames": [
+ "pubKey"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_UnsignedNostrEvent_pubKey` ON `${TABLE_NAME}` (`pubKey`)"
+ },
+ {
+ "name": "index_UnsignedNostrEvent_kind",
+ "unique": false,
+ "columnNames": [
+ "kind"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_UnsignedNostrEvent_kind` ON `${TABLE_NAME}` (`kind`)"
+ }
+ ]
+ },
+ {
+ "tableName": "Zap",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `postId` TEXT NOT NULL, `senderPublicKey` TEXT NOT NULL, `receiverPublicKey` TEXT NOT NULL, `message` TEXT, `invoice` TEXT, `amountInMillisatoshis` INTEGER, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`senderPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`receiverPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`postId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "postId",
+ "columnName": "postId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "senderPublicKey",
+ "columnName": "senderPublicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "receiverPublicKey",
+ "columnName": "receiverPublicKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "message",
+ "columnName": "message",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "invoice",
+ "columnName": "invoice",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "amountInMillisatoshis",
+ "columnName": "amountInMillisatoshis",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "nostrEventId",
+ "columnName": "nostrEventId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "savedAt",
+ "columnName": "savedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "viewedAt",
+ "columnName": "viewedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "deletedAt",
+ "columnName": "deletedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "broadcastedAt",
+ "columnName": "broadcastedAt",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_Zap_senderPublicKey",
+ "unique": false,
+ "columnNames": [
+ "senderPublicKey"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_Zap_senderPublicKey` ON `${TABLE_NAME}` (`senderPublicKey`)"
+ },
+ {
+ "name": "index_Zap_nostrEventId",
+ "unique": false,
+ "columnNames": [
+ "nostrEventId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_Zap_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)"
+ },
+ {
+ "name": "index_Zap_postId",
+ "unique": false,
+ "columnNames": [
+ "postId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_Zap_postId` ON `${TABLE_NAME}` (`postId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "Profile",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "senderPublicKey"
+ ],
+ "referencedColumns": [
+ "publicKey"
+ ]
+ },
+ {
+ "table": "Profile",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "receiverPublicKey"
+ ],
+ "referencedColumns": [
+ "publicKey"
+ ]
+ },
+ {
+ "table": "NostrEvent",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "nostrEventId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "Post",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "postId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'eea30c363f8d70e31d7f5fa306650a58')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 1d0e777a..5f5a9c07 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -514,4 +514,27 @@
You already have a chat with %1$s.
Checking whether %1$s can receive messages here.
%1$s hasn't set up messaging on Mantra yet. Try again later, or ask them to open Mantra.
+ Who adopts this group?
+ Which group should adopt this one?
+ No group you administer can adopt this one. A group can only be offered as a parent by somebody who is in it.
+ Asked that group to adopt this one
+ Parent groups
+ Add a parent group
+ %1$s has offered to adopt this group
+ Accept as a parent
+ A group is a subgroup of another only once its own quorum says so. Accepting asks this group to agree what it signs with again, naming them.
+ Asked this group to accept that parent
+
+ Key ceremonies
+ This group has not held a key ceremony.
+ The group's shared key
+ A subgroup's key
+ A subgroup: %1$s
+ Waiting for members to join.
+ Waiting for everyone to commit their part.
+ Waiting for everyone to confirm the result.
+ The group has a key from this ceremony.
+ Abandoned. No key was created.
+ %1$s of %2$s members will sign with it
+ Set up a shared key
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt
index 66a89663..c0c690ba 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt
@@ -77,6 +77,7 @@ import press.mantra.compose.database.model.GiftWrapMessage
import press.mantra.compose.database.model.GiftWrapPayload
import press.mantra.compose.database.model.GiftWrapSeal
import press.mantra.compose.database.model.GroupKeyState
+import press.mantra.compose.database.model.GroupKeyStateParent
import press.mantra.compose.database.model.GroupSignedEvent
import press.mantra.compose.database.model.InReplyToRelation
import press.mantra.compose.database.model.MantraArtifact
@@ -145,6 +146,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
GiftWrapSeal::class,
GiftWrapPayload::class,
GroupKeyState::class,
+ GroupKeyStateParent::class,
GroupSignedEvent::class,
InReplyToRelation::class,
MantraArtifact::class,
@@ -186,7 +188,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
UnsignedNostrEvent::class,
Zap::class
],
- version = 21,
+ version = 22,
autoMigrations = [
// v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can
// generate the migration itself — nothing existing changes shape.
@@ -330,9 +332,25 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
// which is what they were, and any identity may drain them. The broadcast
// queue deliberately gets no owner: a signed event is anyone's to carry.
AutoMigration(from = 19, to = 20),
- // v21 adds the three project tables -- a project, and one row per source
+ // v21 moves a room's parentage off two GroupKeyState columns onto the new
+ // GroupKeyStateParent table, so a room can be the child of more than one
+ // group -- founded with one and adopted by another later. It copies
+ // before it drops, which no AutoMigration can express, and it drops
+ // ChatRoom.parentChatRoomId as well: that column was a cache of the
+ // verified value which nothing in production ever read, and keeping it
+ // would mean a column holding *one* of several parents, which is worse
+ // than unused. See MIGRATION_20_21 and
+ // docs/subgroups-multiple-parents.md.
+ //
+ // v22 adds the three project tables -- a project, and one row per source
// version or translation that is part of it. Three new tables and
- // nothing existing changes shape, so Room generates this itself.
+ // nothing existing changes shape, so Room generates this one itself.
+ //
+ // It is v22 rather than v21 because two trees allocated out of the same
+ // space in one release window: this landed as 21 on its own branch while
+ // the parentage table above took 21 on `mantra`. Renumbering was free --
+ // nothing had shipped -- but see Phase 0 of docs/project-feature.md,
+ // which says to check, and checked the wrong tree.
//
// A project's contents are foreign keys into this room's own rows, with
// CASCADE, like every other reference in this family. That is the
@@ -345,7 +363,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
// The owners are a comma-joined sorted string on the project row rather
// than a fourth table, which is what DkgSession.participantPublicKeys
// already does with a set of keys and for the same reason.
- AutoMigration(from = 20, to = 21),
+ AutoMigration(from = 21, to = 22),
]
)
@ColumnTypeConverters(MantraConverters::class)
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/builder/PlatformDatabaseBuilder.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/builder/PlatformDatabaseBuilder.kt
index 99ff7ece..212e6c8e 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/builder/PlatformDatabaseBuilder.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/builder/PlatformDatabaseBuilder.kt
@@ -3,6 +3,7 @@ package press.mantra.compose.database.builder
import androidx.room3.RoomDatabase
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import press.mantra.compose.database.migrations.MIGRATION_3_4
+import press.mantra.compose.database.migrations.MIGRATION_20_21
import press.mantra.compose.database.migrations.MIGRATION_9_10
import press.mantra.compose.database.migrations.MIGRATION_13_14
import press.mantra.compose.database.migrations.MIGRATION_14_15
@@ -19,13 +20,21 @@ fun getRoomDatabase(
builder: RoomDatabase.Builder
): press.mantra.compose.database.MantraDatabase {
return builder
- // Everything else Room generates itself. These four move data rather than
+ // Everything else Room generates itself. These five move data rather than
// only changing shape, which an AutoMigration cannot express: 3->4 rewrites
// chat rows, 9->10 copies a session's per-event columns onto the items
// table before dropping them, 13->14 renames a column and rewrites the
- // chat rows that named it the old way, and 14->15 adds a column and deletes
- // the chat rows written for messages from before this device joined.
- .addMigrations(MIGRATION_3_4, MIGRATION_9_10, MIGRATION_13_14, MIGRATION_14_15)
+ // chat rows that named it the old way, 14->15 adds a column and deletes
+ // the chat rows written for messages from before this device joined, and
+ // 20->21 copies each room's single parentage onto the table that can hold
+ // several before dropping the columns it came from.
+ .addMigrations(
+ MIGRATION_3_4,
+ MIGRATION_9_10,
+ MIGRATION_13_14,
+ MIGRATION_14_15,
+ MIGRATION_20_21
+ )
.setDriver(BundledSQLiteDriver())
.setQueryCoroutineContext(Dispatchers.IO)
.build()
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ChatRoomDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ChatRoomDao.kt
index c072cb30..c27e11bf 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ChatRoomDao.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ChatRoomDao.kt
@@ -66,25 +66,14 @@ interface ChatRoomDao {
)
fun observeChatRoomListByUserPublicKey(userPublicKey: String): Flow>
- /**
- * The subgroups of [parentChatRoomId] this device actually holds rooms for.
- *
- * Deliberately not the answer to "what subgroups does this group have" --
- * that is `SubgroupManager.subgroupsOf`, which reads the parent's signed
- * certificates and so can name a child whose room has not been created, or
- * was created for somebody else. This names the ones there is something to
- * open.
- *
- * Soft-deleted rooms are excluded like everywhere else: a room the user has
- * cleared away should not reappear because its parent lists it.
- */
- @Transaction
- @Query(
- CHAT_ROOM_WITH_LAST_MESSAGE +
- "WHERE ChatRoom.parentChatRoomId = :parentChatRoomId AND ChatRoom.deletedAt IS NULL " +
- ORDER_BY_LAST_ACTIVITY
- )
- fun observeByParentChatRoomId(parentChatRoomId: String): Flow>
+ // "The subgroups of this room" used to be a column filter here, over a
+ // ChatRoom.parentChatRoomId that production never read -- the live answer
+ // went through GroupKeyState, and SubgroupManager.subgroupsOf reads the
+ // parent's own certificates, which can name a child whose room this device
+ // does not have. The column is gone at v21, because a room can have more
+ // than one parent and a single column would have held one of them. The
+ // rooms a parent's children occupy are reached through
+ // GroupKeyStateDao.getByParentChatRoomId and resolved from there.
@Upsert
suspend fun upsert(chatRoom: ChatRoom)
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GroupKeyStateDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GroupKeyStateDao.kt
index 4a7f0b6f..f0076895 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GroupKeyStateDao.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GroupKeyStateDao.kt
@@ -6,6 +6,8 @@ import androidx.room3.Transaction
import androidx.room3.Upsert
import kotlinx.coroutines.flow.Flow
import press.mantra.compose.database.model.GroupKeyState
+import press.mantra.compose.database.model.GroupKeyStateParent
+import press.mantra.compose.database.model.intermdiate.LocalGroupKeyState
@Dao
abstract class GroupKeyStateDao {
@@ -15,6 +17,18 @@ abstract class GroupKeyStateDao {
@Query("SELECT * FROM GroupKeyState WHERE chatRoomId = :chatRoomId")
abstract fun observeByChatRoomId(chatRoomId: String): Flow
+ /**
+ * A state with the groups that have certified it, which is how a lineage
+ * should always be read -- the two are one signed statement.
+ */
+ @Transaction
+ @Query("SELECT * FROM GroupKeyState WHERE chatRoomId = :chatRoomId")
+ abstract suspend fun getLocalByChatRoomId(chatRoomId: String): LocalGroupKeyState?
+
+ @Transaction
+ @Query("SELECT * FROM GroupKeyState WHERE chatRoomId = :chatRoomId")
+ abstract fun observeLocalByChatRoomId(chatRoomId: String): Flow
+
/** Every room that signs with one ceremony's key. One, today. */
@Query("SELECT * FROM GroupKeyState WHERE dkgSessionId = :dkgSessionId")
abstract suspend fun getByDkgSessionId(dkgSessionId: String): List
@@ -23,26 +37,52 @@ abstract class GroupKeyStateDao {
* Every subgroup of [parentChatRoomId] this device holds a verified state
* for.
*
- * Verified is the whole of what these rows are: a state only reaches the
- * table with a parent on it once its birth certificate has been checked
- * against that parent's signature, so this is a list of children the parent
- * really did certify rather than a list of rooms claiming a parent.
+ * Verified is the whole of what these rows are: a parentage only reaches the
+ * table once its birth certificate has been checked against that parent's
+ * signature, so this is a list of children the parent really did certify
+ * rather than a list of rooms claiming a parent.
*
* It is not the whole list. A subgroup whose room this device does not have
* leaves no state here at all, and the certificates in `GroupSignedEvent` are
* the fuller answer -- see `SubgroupManager.subgroupsOf`.
+ *
+ * A child with two parents appears in both parents' answers, which is the
+ * point of the join: the row that makes it this parent's child says nothing
+ * about who else certified it.
*/
- @Query("SELECT * FROM GroupKeyState WHERE parentChatRoomId = :parentChatRoomId ORDER BY announcedAt DESC")
- abstract suspend fun getByParentChatRoomId(parentChatRoomId: String): List
+ @Transaction
+ @Query(
+ "SELECT GroupKeyState.* FROM GroupKeyState " +
+ "JOIN GroupKeyStateParent ON GroupKeyStateParent.chatRoomId = GroupKeyState.chatRoomId " +
+ "WHERE GroupKeyStateParent.parentChatRoomId = :parentChatRoomId " +
+ "ORDER BY GroupKeyState.announcedAt DESC"
+ )
+ abstract suspend fun getByParentChatRoomId(parentChatRoomId: String): List
- @Query("SELECT * FROM GroupKeyState WHERE parentChatRoomId = :parentChatRoomId ORDER BY announcedAt DESC")
- abstract fun observeByParentChatRoomId(parentChatRoomId: String): Flow>
+ @Transaction
+ @Query(
+ "SELECT GroupKeyState.* FROM GroupKeyState " +
+ "JOIN GroupKeyStateParent ON GroupKeyStateParent.chatRoomId = GroupKeyState.chatRoomId " +
+ "WHERE GroupKeyStateParent.parentChatRoomId = :parentChatRoomId " +
+ "ORDER BY GroupKeyState.announcedAt DESC"
+ )
+ abstract fun observeByParentChatRoomId(parentChatRoomId: String): Flow>
+
+ /** The parents of one room, as rows. Prefer [getLocalByChatRoomId]. */
+ @Query("SELECT * FROM GroupKeyStateParent WHERE chatRoomId = :chatRoomId")
+ abstract suspend fun getParentsByChatRoomId(chatRoomId: String): List
@Upsert
abstract suspend fun upsert(groupKeyState: GroupKeyState)
+ @Upsert
+ abstract suspend fun upsertParents(parents: List)
+
+ @Query("DELETE FROM GroupKeyStateParent WHERE chatRoomId = :chatRoomId")
+ abstract suspend fun deleteParentsByChatRoomId(chatRoomId: String)
+
/**
- * Files a state, keeping the newest one per room.
+ * Files a state and its whole lineage, keeping the newest one per room.
*
* This is where the event's replaceable semantics actually happen. Relays
* never see a `GroupKeyStateEvent` -- it lives inside a Marmot group and is
@@ -56,13 +96,36 @@ abstract class GroupKeyStateDao {
* state agree by construction, since both derived it from the room they are
* standing in.
*
- * Returns the state now on file.
+ * ### The lineage is replaced, not merged, and that is the whole reason this
+ * is a transaction
+ *
+ * A key state is replaceable and the newest wins **wholesale**, so the set of
+ * parents it names is the set -- not an addition to whatever was there. That
+ * is what makes a disavowal expressible at all: a room drops a parent by
+ * re-announcing without it, and if these rows were merged rather than
+ * rewritten the dropped parent would survive the statement that dropped it.
+ *
+ * Deleting before inserting inside one transaction is also what stops a
+ * reader seeing a state beside somebody else's parents. Were the two writes
+ * separate, a concurrent `getLocalByChatRoomId` could land between them and
+ * read a state with no lineage, or with the old one -- both of which look
+ * exactly like a true answer.
+ *
+ * Returns the state now on file, with the lineage now on file beside it.
*/
@Transaction
- open suspend fun replace(groupKeyState: GroupKeyState): GroupKeyState {
+ open suspend fun replace(
+ groupKeyState: GroupKeyState,
+ parents: List = emptyList(),
+ ): LocalGroupKeyState {
val known = getByChatRoomId(groupKeyState.chatRoomId)
- if (known != null && known.announcedAt >= groupKeyState.announcedAt) return known
+ if (known != null && known.announcedAt >= groupKeyState.announcedAt) {
+ return LocalGroupKeyState(
+ keyState = known,
+ parents = getParentsByChatRoomId(known.chatRoomId)
+ )
+ }
val stamped = groupKeyState.copy(
createdAt = known?.createdAt ?: groupKeyState.createdAt,
@@ -70,6 +133,13 @@ abstract class GroupKeyStateDao {
)
upsert(stamped)
- return stamped
+ // The state's own row has to exist before its parents', because they
+ // reference it. Room turns foreign keys off around a migration but not
+ // around a transaction, so the order here is load-bearing rather than
+ // tidy.
+ deleteParentsByChatRoomId(stamped.chatRoomId)
+ if (parents.isNotEmpty()) upsertParents(parents)
+
+ return LocalGroupKeyState(keyState = stamped, parents = parents)
}
}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/migrations/SubgroupLineageMigration.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/migrations/SubgroupLineageMigration.kt
new file mode 100644
index 00000000..e7869173
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/migrations/SubgroupLineageMigration.kt
@@ -0,0 +1,86 @@
+package press.mantra.compose.database.migrations
+
+import androidx.room3.migration.Migration
+import androidx.sqlite.SQLiteConnection
+import androidx.sqlite.execSQL
+
+/**
+ * Moves a room's parentage off two `GroupKeyState` columns onto
+ * `GroupKeyStateParent`, so a room can be the child of more than one group.
+ *
+ * Not an `AutoMigration`. Room can create the table and it can drop the columns,
+ * but it cannot copy between them, and this migration is entirely about the copy.
+ *
+ * ### Every existing lineage is a set of one
+ *
+ * A group could only be founded with a parent, never adopted by a second, so
+ * every row that carries a parentage carries exactly one. Each becomes one row
+ * in the new table and nothing is lost -- and the rule the new primary key
+ * states, one certificate per child per parent, is already true of all of them.
+ *
+ * Rows with no parentage -- every group made before subgroups, and every
+ * top-level group made after -- contribute nothing, which is what "nobody's
+ * child" looks like in the new shape: no rows rather than a null.
+ *
+ * The `WHERE` takes both halves. A row with one and not the other should not
+ * exist, because `GroupKeyStateManager.stateFrom` has always dropped a state
+ * carrying a half-claim rather than filing it -- but this is the one moment the
+ * data is reshaped rather than re-derived, and a half row copied forward would
+ * become a parent whose certificate is the string "null". Better to leave such a
+ * row behind entirely: the state keeps saying what the room signs with, which is
+ * the part that matters, and loses a lineage it could never have proved.
+ *
+ * ### `ChatRoom.parentChatRoomId` goes with them, unread
+ *
+ * It was a cache of the verified value, written by `MarmotGroupCreation` and
+ * read -- in production -- by nothing at all; the live answer went through
+ * `GroupKeyState`. Keeping it would mean a column holding *one* of several
+ * parents, which is worse than unused: it is a cache that is wrong. So it is
+ * dropped rather than backfilled, and nothing is copied out of it.
+ *
+ * ### Why `DROP COLUMN` rather than a table rebuild
+ *
+ * The usual way to remove columns in SQLite -- create a new table, copy, drop
+ * the old one, rename -- is unsafe here and quietly so. `GroupKeyStateParent`
+ * references `GroupKeyState(chatRoomId)` `ON DELETE CASCADE`, and `DROP TABLE`
+ * fires cascades: with foreign keys enforced it would delete every parent row
+ * this migration had just written. Dropping `ChatRoom` would be far worse, since
+ * most of the schema cascades off it.
+ *
+ * `ALTER TABLE ... DROP COLUMN` avoids the question. It needs SQLite 3.35 and
+ * the columns must be free of indices and constraints -- all three are, and
+ * `getRoomDatabase` pins `BundledSQLiteDriver` on every platform, so the version
+ * is ours rather than the host's. Both tables keep their keys, their foreign keys
+ * and their indices.
+ */
+val MIGRATION_20_21 = object : Migration(20, 21) {
+ override suspend fun migrate(connection: SQLiteConnection) {
+ connection.execSQL(
+ "CREATE TABLE IF NOT EXISTS `GroupKeyStateParent` (" +
+ "`chatRoomId` TEXT NOT NULL, " +
+ "`parentChatRoomId` TEXT NOT NULL, " +
+ "`birthCertificateJson` TEXT NOT NULL, " +
+ "PRIMARY KEY(`chatRoomId`, `parentChatRoomId`), " +
+ "FOREIGN KEY(`chatRoomId`) REFERENCES `GroupKeyState`(`chatRoomId`) " +
+ "ON UPDATE NO ACTION ON DELETE CASCADE )"
+ )
+ connection.execSQL(
+ "CREATE INDEX IF NOT EXISTS `index_GroupKeyStateParent_parentChatRoomId` " +
+ "ON `GroupKeyStateParent` (`parentChatRoomId`)"
+ )
+
+ connection.execSQL(
+ "INSERT OR IGNORE INTO `GroupKeyStateParent` " +
+ "(`chatRoomId`, `parentChatRoomId`, `birthCertificateJson`) " +
+ "SELECT `chatRoomId`, `parentChatRoomId`, `birthCertificateJson` " +
+ "FROM `GroupKeyState` " +
+ "WHERE `parentChatRoomId` IS NOT NULL AND `birthCertificateJson` IS NOT NULL"
+ )
+
+ listOf("parentChatRoomId", "birthCertificateJson").forEach { column ->
+ connection.execSQL("ALTER TABLE `GroupKeyState` DROP COLUMN `$column`")
+ }
+
+ connection.execSQL("ALTER TABLE `ChatRoom` DROP COLUMN `parentChatRoomId`")
+ }
+}
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 3552e6ca..34464476 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
@@ -386,6 +386,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.
*
@@ -395,6 +415,7 @@ data class ChatMessage(
*/
val SUBGROUP_TYPES = setOf(
TYPE_SUBGROUP_CERTIFIED,
+ TYPE_SUBGROUP_ADOPTION_OFFERED,
)
/**
@@ -1460,29 +1481,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/database/model/ChatRoom.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatRoom.kt
index 20dc9cc5..8babc6ff 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatRoom.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatRoom.kt
@@ -112,29 +112,6 @@ data class ChatRoom(
*/
val chronicleRequestedAt: Instant? = null,
- /**
- * The group this room is a subgroup of, or null for a room that is nobody's
- * child.
- *
- * A copy of the verified `GroupKeyState.parentChatRoomId`, kept here so the
- * room list and the room's own screen can answer "whose child is this"
- * without a second read. It is written as the room is created or adopts its
- * key state, and only from a state whose certificate passed
- * `SubgroupBirthCertificateEvent.certifies`.
- *
- * **Never a foreign key onto `ChatRoom`.** Every other reference to a room in
- * this schema cascades, and a self-referential cascade would mean deleting a
- * parent silently deletes its subgroups -- and then, by their own cascades,
- * those rooms' messages, participants, key states, signing sessions and
- * signed events. A user tidying up a group they left would destroy a group
- * they are still in. `RESTRICT` would be no better: it would make a parent
- * undeletable while a child row exists, which is a foreign key deciding a
- * product question. This routinely names a room this device does not have --
- * see `GroupKeyState.parentChatRoomId` -- so a dangling value is the normal,
- * expected state.
- */
- val parentChatRoomId: HexKey? = null,
-
override val createdAt: Instant = Clock.System.now(),
override val updatedAt: Instant = createdAt,
override val savedAt: Instant = createdAt,
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupKeyState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupKeyState.kt
index 05800bf5..a3524da5 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupKeyState.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupKeyState.kt
@@ -94,41 +94,16 @@ data class GroupKeyState(
val announcedAt: Instant,
/**
- * The group this one is a subgroup of, or null for a group that is nobody's
- * child -- which is every group made before subgroups existed.
+ * A lineage used to live here, as a parent id and a certificate. It is
+ * `GroupKeyStateParent` now, one row per parent, because a room can have
+ * more than one -- see `docs/subgroups-multiple-parents.md`. Nothing about
+ * what it means changed in the move, including that the parent side is never
+ * a foreign key onto `ChatRoom`; that entity's comment carries the argument.
*
- * **Not a foreign key, and that is deliberate.** The three tables around this
- * one cascade off `ChatRoom`, and a self-referential cascade here would mean
- * deleting a parent room deletes every subgroup's state beneath it. It could
- * not be `RESTRICT` either: this routinely names a room the device does not
- * have at all -- a member of a subgroup who was never in its parent holds the
- * id and nothing else -- so a dangling value is the normal state and readers
- * resolve it with a lookup allowed to return null.
- *
- * Written only from a state whose [birthCertificateJson] passed
- * `SubgroupBirthCertificateEvent.certifies`, so a row carrying this is a row
- * where the parent's quorum really did sign for this room. See
- * `GroupKeyStateManager.stateFrom`, which drops the whole state rather than
- * keeping a parentage it could not check.
+ * Read the two together through `LocalGroupKeyState`, never apart: a state
+ * and the lineage it claims are one signed statement, and
+ * `GroupKeyStateManager.stateFrom` believes all of it or drops all of it.
*/
- val parentChatRoomId: HexKey? = null,
-
- /**
- * The parent's signed certificate, whole, as JSON -- or null when this group
- * has no parent.
- *
- * The claim itself, where [parentChatRoomId] is only an index into it. Kept
- * as the event rather than as its signature so that a reader can check it
- * again from the row alone: a signature plus a rule for rebuilding the event
- * it covers is a rule that breaks silently the first time the certificate's
- * shape changes, and a rebuild differing by one byte hashes to an id whose
- * signature fails and is indistinguishable from a forgery.
- *
- * Always set together with [parentChatRoomId]. One without the other is a
- * state that never reached this table.
- */
- val birthCertificateJson: String? = null,
-
override val createdAt: Instant = Clock.System.now(),
override val updatedAt: Instant = createdAt,
override val savedAt: Instant = createdAt,
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupKeyStateParent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupKeyStateParent.kt
new file mode 100644
index 00000000..8dda692d
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupKeyStateParent.kt
@@ -0,0 +1,86 @@
+package press.mantra.compose.database.model
+
+import androidx.room3.Entity
+import androidx.room3.ForeignKey
+import androidx.room3.Index
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+
+/**
+ * One group that has certified a room as its child, and the signature saying so.
+ *
+ * A row per parent, because a room can have more than one: it is founded with
+ * one and may be adopted by another later, and an adoption is the parent
+ * certifying plus the child re-announcing its key state with the pair added. See
+ * `docs/subgroups-multiple-parents.md`.
+ *
+ * These rows were two nullable columns on [GroupKeyState] while a lineage could
+ * only ever be one link. Nothing about their meaning changed in the move.
+ *
+ * ### The primary key is the rule
+ *
+ * `(chatRoomId, parentChatRoomId)` -- one certificate per child per parent. A
+ * second entry for a parent already in the set is one parent asked twice and is
+ * refused by `SubgroupLineage.of` before it gets here; the key is what makes
+ * that a property of the store rather than of a reader remembering.
+ *
+ * ### Verified before it is written, every time
+ *
+ * A row exists only where `SubgroupBirthCertificateEvent.certifies` has said
+ * that this parent's quorum really signed for this room -- see
+ * `GroupKeyStateManager.stateFrom`, which drops the **whole** state when any one
+ * parent's certificate fails. So the presence of a row is itself the claim that
+ * the lineage was checked, and there is no state in which some parents are
+ * believed and others are not.
+ *
+ * [birthCertificateJson] is the certificate whole rather than its signature,
+ * so a reader can check it again from the row alone. A signature plus a rule for
+ * rebuilding the event it covers is a rule that breaks silently the first time
+ * the certificate's shape changes: a rebuild differing by one byte hashes to an
+ * id whose signature fails and is indistinguishable from a forgery.
+ *
+ * ### The cascade points at the state, never at the parent's room
+ *
+ * `ON DELETE CASCADE` from [GroupKeyState] is what keeps a lineage from
+ * outliving the state that claimed it -- and it is what lets a re-announcement
+ * that drops a parent actually drop it, since `GroupKeyStateDao.replace`
+ * rewrites these rows inside the same transaction.
+ *
+ * [parentChatRoomId] is deliberately **not** a foreign key onto `ChatRoom`. A
+ * self-referential cascade would mean deleting a parent room deletes its
+ * children's key states, and then by their own cascades those rooms' messages,
+ * participants, signing sessions and signed events -- a user tidying up a group
+ * they left would destroy a group they are still in. `RESTRICT` would be no
+ * better: it would make a parent undeletable while a child row exists, which is
+ * a foreign key deciding a product question. And this routinely names a room the
+ * device does not have at all, since a member of a subgroup need never have been
+ * in its parent, so a dangling value is the normal, expected state and readers
+ * resolve it with a lookup allowed to return null.
+ */
+@Entity(
+ primaryKeys = ["chatRoomId", "parentChatRoomId"],
+ foreignKeys = [
+ ForeignKey(
+ entity = GroupKeyState::class,
+ parentColumns = ["chatRoomId"],
+ childColumns = ["chatRoomId"],
+ onDelete = ForeignKey.CASCADE,
+ )
+ ],
+ indices = [
+ // The composite primary key already indexes chatRoomId as its leftmost
+ // column, so only the other direction needs one: "which rooms has this
+ // group certified", which is how a parent finds the children whose state
+ // this device holds.
+ Index("parentChatRoomId"),
+ ],
+)
+data class GroupKeyStateParent(
+ /** The child: the room whose key state carries this parentage. */
+ val chatRoomId: String,
+
+ /** The parent's room id, which is also the key its certificate verifies against. */
+ val parentChatRoomId: HexKey,
+
+ /** The parent's signed certificate, whole, as JSON. */
+ val birthCertificateJson: String,
+)
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalGroupKeyState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalGroupKeyState.kt
new file mode 100644
index 00000000..30991c56
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalGroupKeyState.kt
@@ -0,0 +1,62 @@
+package press.mantra.compose.database.model.intermdiate
+
+import androidx.room3.Embedded
+import androidx.room3.Relation
+import com.vitorpamplona.quartz.nip01Core.core.Event
+import press.mantra.compose.database.model.GroupKeyState
+import press.mantra.compose.database.model.GroupKeyStateParent
+import press.mantra.compose.nostr.subgroup.SubgroupLineage
+import press.mantra.compose.nostr.subgroup.SubgroupParentage
+
+/**
+ * A room's key state with every group that has certified it as a child.
+ *
+ * The two always travel together, because a state and the lineage it claims are
+ * one signed statement: `GroupKeyStateManager.stateFrom` either believes all of
+ * it or drops all of it, so a reader holding the state without the parents would
+ * be holding half of something that has no halves.
+ *
+ * Also the shape `stateFrom` returns. It is a Room read model -- `@Embedded`
+ * over `@Relation`, like `LocalChatRoom` -- and a plain data class besides, so
+ * the pure verifier can build one with no database anywhere near it.
+ */
+data class LocalGroupKeyState(
+ @Embedded
+ val keyState: GroupKeyState,
+
+ @Relation(
+ entity = GroupKeyStateParent::class,
+ parentColumns = ["chatRoomId"],
+ entityColumns = ["chatRoomId"],
+ )
+ val parents: List = emptyList(),
+) {
+ /**
+ * The lineage these rows amount to, or null for a room that is nobody's
+ * child.
+ *
+ * Null also where the rows cannot be read back as a lineage -- a certificate
+ * whose JSON no longer parses, or a repeat the store should have made
+ * impossible. Both are the same answer here, and it is the safe one: a
+ * lineage that cannot be stated is not half-stated, for the reason
+ * `stateFrom` drops a whole state rather than keeping a parentage it could
+ * not check.
+ *
+ * Rebuilt rather than cached, so the canonical order and the no-repeats rule
+ * come from `SubgroupLineage` on every read instead of from whatever order
+ * the rows came back in.
+ */
+ fun lineage(): SubgroupLineage? = SubgroupLineage.of(
+ parents.mapNotNull { parent ->
+ Event.fromJsonOrNull(parent.birthCertificateJson)?.let { certificate ->
+ SubgroupParentage(
+ parentChatRoomId = parent.parentChatRoomId,
+ certificate = certificate
+ )
+ }
+ }.takeIf { it.size == parents.size } ?: return null
+ )
+
+ /** The groups that have certified this room, in canonical order. */
+ fun parentChatRoomIds(): List = lineage()?.parentChatRoomIds.orEmpty()
+}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt
index 6b71b5e8..3463a4e5 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt
@@ -55,7 +55,6 @@ class DatabaseChatRepository(
userPublicKey: HexKey,
keyPair: KeyPair,
path: List,
- parentChatRoomId: HexKey?,
): MarmotGroupCreation.Outcome = MarmotGroupCreation.create(
database = database,
chatRepository = this,
@@ -66,7 +65,6 @@ class DatabaseChatRepository(
userPublicKey = userPublicKey,
keyPair = keyPair,
path = path,
- parentChatRoomId = parentChatRoomId,
)
override suspend fun observeChatRoomListByPublicKey(publicKey: String): Flow> {
return database.chatRoomDao().observeChatRoomListByUserPublicKey(publicKey)
@@ -113,11 +111,92 @@ class DatabaseChatRepository(
emptyList()
}
- override suspend fun parentOf(chatRoomId: String): HexKey? = try {
- SubgroupManager.parentOf(database, chatRoomId)
+ override suspend fun adoptionOffersFor(chatRoomId: String): List = try {
+ SubgroupManager.offersTo(database, chatRoomId)
} catch (e: Throwable) {
- logger.e("Error reading the parent of $chatRoomId", e)
- null
+ logger.e("Error reading the adoption offers for $chatRoomId", e)
+ emptyList()
+ }
+
+ override suspend fun adoptingCandidatesFor(
+ chatRoomId: String,
+ userPublicKey: HexKey
+ ): List = try {
+ SubgroupManager.adoptingCandidatesFor(
+ database = database,
+ childChatRoomId = chatRoomId,
+ userPublicKey = userPublicKey
+ )
+ } catch (e: Throwable) {
+ logger.e("Error reading the groups that could adopt $chatRoomId", e)
+ emptyList()
+ }
+
+ override suspend fun refuseAdoption(
+ chatRoomId: String,
+ adoptingChatRoomId: String,
+ userPublicKey: HexKey
+ ): String? = try {
+ val adopting = database.chatRoomDao().findChatRoomById(adoptingChatRoomId)
+ ?: return "Couldn't load that group"
+
+ SubgroupManager.refuseAdoption(
+ database = database,
+ adoptingRoom = adopting,
+ childChatRoomId = chatRoomId,
+ userPublicKey = userPublicKey
+ )
+ } catch (e: Throwable) {
+ logger.e("Error checking whether $adoptingChatRoomId can adopt $chatRoomId", e)
+ // A refusal rather than a pass. A guard that could not run has not said
+ // yes, and the manager checks again anyway.
+ "Couldn't check whether that group can adopt this one"
+ }
+
+ override suspend fun proposeAdoption(
+ chatRoomId: String,
+ adoptingChatRoomId: String,
+ userPublicKey: HexKey
+ ): Boolean = try {
+ val adopting = checkNotNull(database.chatRoomDao().findChatRoomById(adoptingChatRoomId))
+ val child = checkNotNull(database.chatRoomDao().findChatRoomById(chatRoomId))
+
+ SubgroupManager.proposeAdoption(
+ database = database,
+ adoptingRoom = adopting,
+ childRoom = child,
+ userPublicKey = userPublicKey
+ )
+ true
+ } catch (e: Throwable) {
+ logger.e("Error asking $adoptingChatRoomId to adopt $chatRoomId", e)
+ false
+ }
+
+ override suspend fun acceptParent(
+ chatRoomId: String,
+ parentChatRoomId: HexKey,
+ userPublicKey: HexKey
+ ): Boolean = try {
+ val child = checkNotNull(database.chatRoomDao().findChatRoomById(chatRoomId))
+
+ SubgroupManager.proposeAcceptance(
+ database = database,
+ childRoom = child,
+ userPublicKey = userPublicKey,
+ parentChatRoomId = parentChatRoomId
+ )
+ true
+ } catch (e: Throwable) {
+ logger.e("Error asking $chatRoomId to accept $parentChatRoomId as a parent", e)
+ false
+ }
+
+ override suspend fun parentsOf(chatRoomId: String): List = try {
+ SubgroupManager.parentsOf(database, chatRoomId)
+ } catch (e: Throwable) {
+ logger.e("Error reading the parents of $chatRoomId", e)
+ emptyList()
}
override suspend fun groupSignedEvent(chatRoomId: String, id: HexKey): GroupSignedEvent? = try {
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt
index e1674fc5..fcb53e1f 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt
@@ -5,7 +5,7 @@ import press.mantra.compose.database.model.DkgParticipantMessage
import press.mantra.compose.database.model.DkgSession
import press.mantra.compose.database.model.types.DkgRitualStage
import press.mantra.compose.database.model.FrostSigningSession
-import press.mantra.compose.database.model.GroupKeyState
+import press.mantra.compose.database.model.intermdiate.LocalGroupKeyState
import press.mantra.compose.database.model.GroupSignedEvent
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.database.model.intermdiate.LocalFrostSigningSession
@@ -110,29 +110,30 @@ class DatabaseDkgRepository(
): Flow> =
database.frostSigningSessionDao().observeSessionsForChatRoom(chatRoomId)
- override fun observeSignedGroupKeyState(dkgSessionId: String): Flow =
+ /**
+ * By session id, not by room. A ceremony room holds more than one ceremony
+ * the moment a subgroup's admins are the whole group, and a lookup by room
+ * answered about whichever the room's rule picked -- for a room holding only
+ * a subgroup's ceremony, asking for the room's *own* found nothing at all,
+ * and a finished subgroup with a signed key state could never be created.
+ */
+ override suspend fun roomDerivedBy(dkgSessionId: String): String? =
+ database.dkgSessionDao()
+ .getSessionById(dkgSessionId)
+ ?.thresholdPublicKey
+ ?.let { runCatching { SharedKeyDerivation.marmotGroupId(it) }.getOrNull() }
+
+ override fun observeSignedGroupKeyState(dkgSessionId: String): Flow =
database.groupSignedEventDao()
.observeByKind(GroupKeyStateEvent.KIND)
.map { signedEvents ->
- // The room this ceremony's key derives, resolved per emission
- // rather than once: the ceremony has no key until it finishes,
- // and this flow is running before it does.
- //
- // By session id, not by room. A ceremony room holds more than one
- // ceremony the moment a subgroup's admins are the whole group, and
- // a lookup by room answered about whichever the room's rule picked
- // -- for a room holding only a subgroup's ceremony, asking for the
- // room's *own* found nothing at all, and a finished subgroup with
- // a signed key state could never be created.
- val subjectRoomId = database.dkgSessionDao()
- .getSessionById(dkgSessionId)
- ?.thresholdPublicKey
- ?.let { runCatching { SharedKeyDerivation.marmotGroupId(it) }.getOrNull() }
-
- subjectRoomId?.let { GroupKeyStateManager.stateAmong(signedEvents, it) }
+ // Resolved per emission rather than once: the ceremony has no key
+ // until it finishes, and this flow is running before it does.
+ roomDerivedBy(dkgSessionId)
+ ?.let { GroupKeyStateManager.stateAmong(signedEvents, it) }
}
- override suspend fun adoptGroupKeyState(chatRoomId: String): GroupKeyState? = try {
+ override suspend fun adoptGroupKeyState(chatRoomId: String): LocalGroupKeyState? = try {
GroupKeyStateManager.adopt(database = database, chatRoomId = chatRoomId)
} catch (e: Throwable) {
// The room is made and usable either way; what it loses is the shortcut
@@ -176,14 +177,9 @@ class DatabaseDkgRepository(
database.groupSignedEventDao()
.observeByKind(SubgroupBirthCertificateEvent.KIND)
.map { signedEvents ->
- // The room the ceremony's key derives, resolved per emission
- // rather than once: the ceremony has no key until it finishes,
- // and this flow is running before it does.
- val subgroupChatRoomId = database.dkgSessionDao()
- .getSessionById(dkgSessionId)
- ?.thresholdPublicKey
- ?.let { runCatching { SharedKeyDerivation.marmotGroupId(it) }.getOrNull() }
- ?: return@map null
+ // Resolved per emission rather than once: the ceremony has no key
+ // until it finishes, and this flow is running before it does.
+ val subgroupChatRoomId = roomDerivedBy(dkgSessionId) ?: return@map null
signedEvents
.filter {
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt
index 780d80e3..757221c8 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt
@@ -9,10 +9,13 @@ import press.mantra.compose.database.MantraDatabase
import press.mantra.compose.database.model.DkgSession
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.database.model.GroupKeyState
+import press.mantra.compose.database.model.GroupKeyStateParent
import press.mantra.compose.database.model.GroupSignedEvent
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
+import press.mantra.compose.database.model.intermdiate.LocalGroupKeyState
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
/**
@@ -112,7 +115,7 @@ object GroupKeyStateManager {
userPublicKey: HexKey,
key: DkgSession,
path: List = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH,
- parent: SubgroupParentage? = null,
+ lineage: SubgroupLineage? = null,
createdAt: Long = Clock.System.now().epochSeconds
): FrostSigningSession {
val signingRoomId = localChatRoom.chatRoom.id
@@ -137,9 +140,7 @@ object GroupKeyStateManager {
thresholdPublicKey = thresholdPublicKey,
derivationPath = SharedKeyDerivation.formatPath(path),
announcedBy = userPublicKey,
- announcedAt = Instant.fromEpochSeconds(createdAt),
- parentChatRoomId = parent?.parentChatRoomId,
- birthCertificateJson = parent?.certificate?.toJson()
+ announcedAt = Instant.fromEpochSeconds(createdAt)
)
check(state.verifies()) {
@@ -147,10 +148,14 @@ object GroupKeyStateManager {
}
// Refused here rather than discovered by the group. Every device that
- // receives this state runs the same check in `parentageOf` and drops the
- // state when it fails, so proposing one this device would not believe
- // spends a quorum's attention on a statement nobody will keep.
- if (parent != null) {
+ // receives this state runs the same checks in `lineageOf` and drops the
+ // state when any of them fails, so proposing one this device would not
+ // believe spends a quorum's attention on a statement nobody will keep.
+ //
+ // Every parent, because the receiving check is all-or-nothing: one bad
+ // certificate in a set of two drops the whole state, so a proposal is
+ // only worth making if every link in it holds.
+ lineage?.parents?.forEach { parent ->
check(
SubgroupBirthCertificateEvent.certifies(
event = parent.certificate,
@@ -176,7 +181,7 @@ object GroupKeyStateManager {
chatRoomId = chatRoomId,
dkgSessionId = key.id,
path = path,
- parent = parent
+ lineage = lineage
),
content = thresholdPublicKey,
key = key,
@@ -201,18 +206,19 @@ object GroupKeyStateManager {
database: MantraDatabase,
chatRoomId: String,
innerEvent: Event
- ): GroupKeyState? {
- val state = stateFrom(chatRoomId, innerEvent) ?: return null
+ ): LocalGroupKeyState? {
+ val local = stateFrom(chatRoomId, innerEvent) ?: return null
+ val announced = local.keyState.chatRoomId
- if (database.chatRoomDao().findChatRoomById(state.chatRoomId) == null) {
+ if (database.chatRoomDao().findChatRoomById(announced) == null) {
logger.i(
- "The group signed a key state for ${state.chatRoomId}, which does not exist " +
+ "The group signed a key state for $announced, which does not exist " +
"yet; holding it until it does"
)
return null
}
- return database.groupKeyStateDao().replace(state)
+ return database.groupKeyStateDao().replace(local.keyState, local.parents)
}
/**
@@ -235,12 +241,15 @@ object GroupKeyStateManager {
* Idempotent: `replace` keeps the newest state per room, so adopting twice
* settles on the same row.
*/
- suspend fun adopt(database: MantraDatabase, chatRoomId: String): GroupKeyState? {
- val state = signedStateFor(database, chatRoomId) ?: return null
+ suspend fun adopt(database: MantraDatabase, chatRoomId: String): LocalGroupKeyState? {
+ val local = signedStateFor(database, chatRoomId) ?: return null
- logger.i("Room $chatRoomId adopts the key state its group signed at ${state.announcedAt}")
+ logger.i(
+ "Room $chatRoomId adopts the key state its group signed at " +
+ "${local.keyState.announcedAt}, naming ${local.parents.size} parents"
+ )
- return database.groupKeyStateDao().replace(state)
+ return database.groupKeyStateDao().replace(local.keyState, local.parents)
}
/**
@@ -253,7 +262,7 @@ object GroupKeyStateManager {
* the ritual screen gates the "create the room" button on, and what [adopt]
* files once there is a room to file it against.
*/
- suspend fun signedStateFor(database: MantraDatabase, chatRoomId: String): GroupKeyState? =
+ suspend fun signedStateFor(database: MantraDatabase, chatRoomId: String): LocalGroupKeyState? =
stateAmong(database.groupSignedEventDao().getByKind(GroupKeyStateEvent.KIND), chatRoomId)
/**
@@ -282,7 +291,7 @@ object GroupKeyStateManager {
* arrive can ask the same question of each emission -- which is how the
* ceremony screen knows the group has finished agreeing.
*/
- fun stateAmong(signedEvents: List, chatRoomId: String): GroupKeyState? =
+ fun stateAmong(signedEvents: List, chatRoomId: String): LocalGroupKeyState? =
signedAmong(signedEvents, chatRoomId)?.second
/**
@@ -297,7 +306,7 @@ object GroupKeyStateManager {
private fun signedAmong(
signedEvents: List,
chatRoomId: String
- ): Pair? =
+ ): Pair? =
signedEvents
.asSequence()
.filter { it.kind == GroupKeyStateEvent.KIND }
@@ -306,7 +315,7 @@ object GroupKeyStateManager {
// every room at once, so a state that names no room names nothing.
.filter { GroupKeyStateEvent.parseChatRoomId(it.tags) == chatRoomId }
.mapNotNull { signed -> stateFrom(chatRoomId, signed.toEvent())?.let { signed to it } }
- .maxByOrNull { (_, state) -> state.announcedAt }
+ .maxByOrNull { (_, local) -> local.keyState.announcedAt }
/**
* The state an event amounts to, or null if it amounts to none.
@@ -345,20 +354,21 @@ object GroupKeyStateManager {
* names no room at all, and appears in the logs.
*/
/**
- * A parentage that was checked, wrapping a value that is allowed to be
+ * A lineage that was checked, wrapping a value that is allowed to be
* absent.
*
* Three outcomes have to be told apart and a bare nullable can only carry
* two. A state may claim no parent, which is every group made before
- * subgroups and every top-level group made after; it may claim one and prove
- * it; or it may claim one it cannot prove, which is not a state with a field
- * missing but a device asserting a relationship the parent never agreed to.
- * The third has to stop [stateFrom] rather than be flattened into the first.
+ * subgroups and every top-level group made after; it may claim some and
+ * prove them all; or it may claim some it cannot prove, which is not a state
+ * with a field missing but a device asserting a relationship a parent never
+ * agreed to. The third has to stop [stateFrom] rather than be flattened into
+ * the first.
*
* So `null` here means refused, and a present [value] of `null` means none
* claimed.
*/
- private class Parentage(val value: SubgroupParentage?)
+ private class Lineage(val value: SubgroupLineage?)
/**
* The parentage an event actually establishes, or null if it claims one it
@@ -374,61 +384,80 @@ object GroupKeyStateManager {
* not a state with one field wrong, and half-believing it would file a group
* as top-level that its own members will see as a subgroup.
*/
- private fun parentageOf(
+ private fun lineageOf(
announced: String,
thresholdPublicKey: HexKey,
innerEvent: Event
- ): Parentage? {
- if (!GroupKeyStateEvent.claimsParentage(innerEvent.tags)) return Parentage(null)
+ ): Lineage? {
+ if (!GroupKeyStateEvent.claimsParentage(innerEvent.tags)) return Lineage(null)
- // Both or neither. A parent named with no certificate is a claim with the
- // checkable part removed; a certificate with no parent named beside it
- // has nothing to be an index of. Either alone is refused rather than
- // completed from the other, because completing it would be this device
- // deciding what the group meant.
- val parentChatRoomId = GroupKeyStateEvent.parseParentChatRoomId(innerEvent.tags)
- val certificate = GroupKeyStateEvent.parseBirthCertificate(innerEvent.tags)
+ // Both or neither, per parent, and no parent twice -- all of which
+ // `parseLineage` decides. A parent named with no certificate is a claim
+ // with the checkable part removed; a certificate with no parent named
+ // beside it has nothing to be an index of. Either alone is refused rather
+ // than completed from the other, because completing it would be this
+ // device deciding what the group meant.
+ val lineage = GroupKeyStateEvent.parseLineage(innerEvent.tags)
- if (parentChatRoomId == null || certificate == null) {
+ if (lineage == null) {
logger.w(
- "Key state for $announced claims a parent in a form that cannot be read " +
- "(parent=${parentChatRoomId != null}, certificate=${certificate != null}); dropping"
+ "Key state for $announced claims a parentage in a form that cannot be read; " +
+ "dropping"
)
return null
}
- // The parent's own quorum, over this very room's id. Everything that
- // makes a lineage worth anything is in here -- see
- // `SubgroupBirthCertificateEvent.certifies`, which needs no lookup and no
- // key it has to be told.
- if (!SubgroupBirthCertificateEvent.certifies(certificate, announced, parentChatRoomId)) {
- logger.w(
- "Key state for $announced carries no certificate by $parentChatRoomId for " +
- "that room; dropping"
- )
- return null
+ // Every parent, or none of them. One bad certificate drops the whole
+ // state rather than the one pair, for the reason the all-or-nothing rule
+ // was written: a state that claims a lineage it cannot back is a device
+ // asserting a relationship somebody never agreed to, and keeping the
+ // provable half would file a group as having one parent when its own
+ // members see two. A set that is partly believed is not a weaker set.
+ lineage.parents.forEach { parentage ->
+ val parentChatRoomId = parentage.parentChatRoomId
+ val certificate = parentage.certificate
+
+ // The parent's own quorum, over this very room's id. Everything that
+ // makes a lineage worth anything is in here -- see
+ // `SubgroupBirthCertificateEvent.certifies`, which needs no lookup,
+ // no key it has to be told, and no knowledge of the other parents.
+ // Each link is checked on its own, which is why a lineage can be
+ // assembled a parent at a time.
+ if (!SubgroupBirthCertificateEvent.certifies(certificate, announced, parentChatRoomId)) {
+ logger.w(
+ "Key state for $announced carries no certificate by $parentChatRoomId for " +
+ "that room; dropping"
+ )
+ return null
+ }
+
+ // Belt and braces: the id already derives from this key, and the
+ // certificate's id already derives from the key it names, so two
+ // certificates for one room cannot name two keys. Stated anyway
+ // because the two facts live in different places and a future change
+ // to either should have to notice this.
+ val certifiedKey =
+ SubgroupBirthCertificateEvent.parseThresholdPublicKey(certificate.tags)
+ if (!thresholdPublicKey.equals(certifiedKey, ignoreCase = true)) {
+ logger.w(
+ "Key state for $announced names $thresholdPublicKey and the certificate " +
+ "by $parentChatRoomId names $certifiedKey; dropping"
+ )
+ return null
+ }
}
- // Belt and braces: the id already derives from this key, and the
- // certificate's id already derives from the key it names, so two
- // certificates for one room cannot name two keys. Stated anyway because
- // the two facts live in different places and a future change to either
- // should have to notice this.
- val certifiedKey = SubgroupBirthCertificateEvent.parseThresholdPublicKey(certificate.tags)
- if (!thresholdPublicKey.equals(certifiedKey, ignoreCase = true)) {
- logger.w(
- "Key state for $announced names $thresholdPublicKey and its certificate names " +
- "$certifiedKey; dropping"
- )
- return null
- }
-
- return Parentage(
- SubgroupParentage(parentChatRoomId = parentChatRoomId, certificate = certificate)
- )
+ // Deliberately not checked: whether the parents know about each other.
+ // An earlier certificate cannot name a later co-parent -- the first
+ // parent signed before the second was a possibility -- so requiring them
+ // to agree on the set would make every adoption cost a fresh quorum in
+ // every group that had already certified this room. The child's key state
+ // is the sole authority on the set; each parent has a veto on being added
+ // and none on who else is in it. See docs/subgroups-multiple-parents.md.
+ return Lineage(lineage)
}
- fun stateFrom(chatRoomId: String, innerEvent: Event): GroupKeyState? {
+ fun stateFrom(chatRoomId: String, innerEvent: Event): LocalGroupKeyState? {
val announced = GroupKeyStateEvent.parseChatRoomId(innerEvent.tags) ?: chatRoomId
val thresholdPublicKey = GroupKeyStateEvent.parseThresholdPublicKey(innerEvent.content)
@@ -461,7 +490,7 @@ object GroupKeyStateManager {
return null
}
- val parent = parentageOf(announced, thresholdPublicKey, innerEvent) ?: return null
+ val lineage = lineageOf(announced, thresholdPublicKey, innerEvent) ?: return null
val state = GroupKeyState(
chatRoomId = announced,
@@ -469,9 +498,7 @@ object GroupKeyStateManager {
thresholdPublicKey = thresholdPublicKey,
derivationPath = SharedKeyDerivation.formatPath(path),
announcedBy = innerEvent.pubKey,
- announcedAt = Instant.fromEpochSeconds(innerEvent.createdAt),
- parentChatRoomId = parent.value?.parentChatRoomId,
- birthCertificateJson = parent.value?.certificate?.toJson()
+ announcedAt = Instant.fromEpochSeconds(innerEvent.createdAt)
)
// Half the trust model, in one line, and the half that does not care who
@@ -484,6 +511,15 @@ object GroupKeyStateManager {
return null
}
- return state
+ return LocalGroupKeyState(
+ keyState = state,
+ parents = lineage.value?.parents?.map {
+ GroupKeyStateParent(
+ chatRoomId = announced,
+ parentChatRoomId = it.parentChatRoomId,
+ birthCertificateJson = it.certificate.toJson()
+ )
+ }.orEmpty()
+ )
}
}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotGroupCreation.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotGroupCreation.kt
index c25a8c11..841bbff4 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotGroupCreation.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotGroupCreation.kt
@@ -93,10 +93,6 @@ object MarmotGroupCreation {
* appended to it, because MIP-01 has no field for one and the path is what
* rebuilds the `TweakCache` a signing session needs.
*
- * [parentChatRoomId] is written onto the room when this is a subgroup. It is
- * the verified value off the child's own key state -- never a claim off a
- * proposal.
- *
* [name] is put under [MarmotGroupName] here rather than trusted from the
* caller. Every Marmot room on a device is a `#name`, and this is the funnel
* both flows that make one already go through, so it is the one place the
@@ -112,7 +108,6 @@ object MarmotGroupCreation {
userPublicKey: HexKey,
keyPair: KeyPair,
path: List = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH,
- parentChatRoomId: HexKey? = null,
keyPackageLookupTimeout: Long = KEY_PACKAGE_LOOKUP_TIMEOUT
): Outcome {
chatRepository.getChatRoomByIdentifier(groupId)?.let { return Outcome.Existing(it) }
@@ -163,15 +158,6 @@ object MarmotGroupCreation {
mlsGroupState = group.saveState().encodeTls().toHex()
) ?: return Outcome.Failed("Couldn't create the group. Please try again.")
- if (parentChatRoomId != null) {
- // Written after the room exists rather than passed into its creation,
- // because `getOrCreateChatRoom` is shared with every other way a room
- // appears and none of them has a parent to hand it.
- database.chatRoomDao().upsert(
- localChatRoom.chatRoom.copy(parentChatRoomId = parentChatRoomId)
- )
- }
-
// The room comes into existence already knowing what it signs with. The
// group agreed that before any of this ran and this device has been
// holding the signed statement since; this is simply the first moment
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 79c3ab7a..a10c237c 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt
@@ -11,6 +11,7 @@ import press.mantra.compose.database.model.GroupSignedEvent
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.database.model.types.DkgRitualStage
import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent
+import press.mantra.compose.nostr.subgroup.SubgroupLineage
import press.mantra.compose.nostr.subgroup.SubgroupParentage
/**
@@ -71,6 +72,18 @@ object SubgroupManager {
*/
const val MINIMUM_ADMINS: Int = 3
+ /**
+ * How far an ancestor walk goes before it gives up.
+ *
+ * A lineage is a DAG rather than a tree once a room can have two parents, so
+ * "is this group already beneath that one" is a graph walk. Bounded because a
+ * cycle is constructible: a child's quorum can certify its own parent -- the
+ * parent's threshold key is public -- and the parent's quorum can re-announce
+ * a state accepting it. Both have to cooperate in something pointless, which
+ * is no reason to let a reader loop forever.
+ */
+ const val MAXIMUM_LINEAGE_DEPTH: Int = 16
+
/**
* Why these admins cannot hold a ceremony together, or null when they can.
*
@@ -320,13 +333,414 @@ object SubgroupManager {
userPublicKey = userPublicKey,
key = key,
path = path,
- parent = SubgroupParentage(
- parentChatRoomId = parentChatRoomId,
- certificate = certificate.toEvent()
+ lineage = SubgroupLineage.of(
+ SubgroupParentage(
+ parentChatRoomId = parentChatRoomId,
+ certificate = certificate.toEvent()
+ )
)
)
}
+ // ---- Adoption: a group that already exists gaining a parent -------------
+ //
+ // Two signings and no ceremony, which is the whole of why this is cheap: the
+ // key exists, so there is no ChillDKG, and the room exists, so there is
+ // nothing to create.
+ //
+ // ```
+ // 1. birth certificate, in the adopting group -> its quorum signs the child's id
+ // 2. key state, in the child itself -> the child's quorum re-announces,
+ // carrying every certificate it accepts
+ // ```
+ //
+ // The order is forced, not chosen: the key state carries the certificate, so
+ // the certificate has to exist first. Which means **the parent commits first
+ // and the child decides second** -- step 1 is an offer, and step 2 is the
+ // acceptance. Picking a parent is not choosing on the child's behalf.
+ //
+ // Both steps need somebody in both groups, and there is no way around it:
+ // step 1 needs the child's threshold key, which only the child's members
+ // hold, and step 2 needs the certificate, which `recordSignedEvents` files
+ // only for the adopting group's members. They need not be the same person and
+ // their capabilities differ. See docs/subgroups-multiple-parents.md.
+
+ /**
+ * Why [adoptingRoom] cannot offer to adopt [childChatRoomId], or null when it
+ * can.
+ *
+ * Read by the picker so nothing is offered that could only fail, and again
+ * before anything is published, because a screen not drawing something is not
+ * a guard. Each message says what to do rather than what is wrong.
+ *
+ * "This device" throughout means **the profile in the chair**, and
+ * `FrostSigningManager.canSign` does not currently tell the two apart -- see
+ * the note under docs/subgroups-multiple-parents.md. These refusals are
+ * honest about capability and silent about persona.
+ */
+ suspend fun refuseAdoption(
+ database: MantraDatabase,
+ adoptingRoom: LocalChatRoom,
+ childChatRoomId: String,
+ userPublicKey: HexKey
+ ): String? {
+ val adoptingChatRoomId = adoptingRoom.chatRoom.id
+
+ // The certificate is signed by the adopting group's key, so a device
+ // holding no share of it cannot open the session at all --
+ // `proposeSigningBatch` throws outright, and throwing at a button is not
+ // a UI. First, because it is the one refusal the user cannot fix by
+ // picking differently.
+ if (!FrostSigningManager.canSign(database, adoptingChatRoomId)) {
+ return "Only a member who holds a share of that group's key can adopt a group into it."
+ }
+
+ // Read off the room's own `MarmotGroupData` rather than off
+ // `Participant.adminAt`, for the reason `refuseSubgroup` gives: that
+ // column is written only by `processGroupMembershipChanges` on an
+ // arriving commit, so on a room this device created it is null for
+ // everybody, including the creator.
+ val adoptingAdmins = runCatching {
+ adoptingRoom.chatRoom.toMlsGroup()?.currentMarmotData()?.adminPubkeys
+ }.getOrNull()
+ ?: return "A group can only adopt another if it has admins."
+
+ if (!adoptingAdmins.contains(userPublicKey)) {
+ return "Only an admin of that group can adopt a group into it."
+ }
+
+ if (adoptingChatRoomId.equals(childChatRoomId, ignoreCase = true)) {
+ return "A group cannot be its own parent."
+ }
+
+ // The certificate carries the child's threshold key, and without it
+ // `certifies` fails its fourth question on every device including this
+ // one. That key lives on the child's own key state, which only the
+ // child's members hold -- so this is the overlap requirement showing up
+ // as a refusal.
+ val child = database.groupKeyStateDao().getLocalByChatRoomId(childChatRoomId)
+ ?: return "This group has not agreed what it signs with, so there is nothing to certify."
+
+ if (child.lineage()?.names(adoptingChatRoomId) == true) {
+ return "That group has already adopted this one."
+ }
+
+ // One certificate per child per parent. A second is a `d`-tag replacement
+ // of the first rather than a second adoption, and spending a quorum's
+ // attention to restate something they have already signed is worse than
+ // doing nothing.
+ if (certificateFor(database, childChatRoomId, adoptingChatRoomId) != null) {
+ return "That group has already offered to adopt this one."
+ }
+
+ // A cycle, as far as this device can see -- and only as far as that. The
+ // walk is bounded and visited-set guarded because a lineage is a DAG now
+ // rather than a tree: with one parent nothing was ever tempted to recurse.
+ if (isAncestorOf(database, childChatRoomId, adoptingChatRoomId)) {
+ return "That group is already a subgroup of this one."
+ }
+
+ return null
+ }
+
+ /**
+ * Whether [candidate] is [chatRoomId] or anything beneath it, as far as this
+ * device's own verified lineages reach.
+ *
+ * Bounded and visited-set guarded. A cycle is constructible -- two quorums
+ * cooperating in something pointless -- and more to the point a lineage is a
+ * DAG now, so the fan-out makes an unbounded walk worth refusing to write.
+ *
+ * Advisory. A device sees only the lineages it holds states for, so a false
+ * answer here is "I cannot see one", never "there is none". The real
+ * protection is that an adoption needs the child's own quorum.
+ */
+ private suspend fun isAncestorOf(
+ database: MantraDatabase,
+ chatRoomId: String,
+ candidate: String,
+ depth: Int = MAXIMUM_LINEAGE_DEPTH
+ ): Boolean {
+ val seen = mutableSetOf(chatRoomId.lowercase())
+ var frontier = listOf(chatRoomId)
+
+ repeat(depth) {
+ if (frontier.isEmpty()) return false
+
+ val children = frontier.flatMap { parent ->
+ database.groupKeyStateDao().getByParentChatRoomId(parent)
+ .map { it.keyState.chatRoomId }
+ }
+
+ if (children.any { it.equals(candidate, ignoreCase = true) }) return true
+
+ frontier = children.filter { seen.add(it.lowercase()) }
+ }
+
+ return false
+ }
+
+ /**
+ * Groups that have offered to adopt [chatRoomId] and are not its parents yet,
+ * newest offer first.
+ *
+ * Read off every certificate this device holds, in any room, rather than off
+ * the room this one is -- a certificate about this group is signed in the
+ * *other* group's room, so it is filed against that room and would never be
+ * found by asking this one. Each is checked by [classify], which verifies the
+ * signature against the group it names and needs no lookup to do it.
+ *
+ * Already-parents are dropped, because an offer that has been accepted is
+ * not an offer any more; the lineage is where that shows.
+ */
+ suspend fun offersTo(database: MantraDatabase, chatRoomId: String): List {
+ val accepted = parentsOf(database, chatRoomId).map { it.lowercase() }.toSet()
+
+ return database.groupSignedEventDao()
+ .getByKind(SubgroupBirthCertificateEvent.KIND)
+ .sortedByDescending { it.createdAt }
+ .mapNotNull { signed ->
+ (classify(chatRoomId, signed.toEvent())
+ as? ArrivingCertificate.OfferToUs)?.parentChatRoomId
+ }
+ .filterNot { it.lowercase() in accepted }
+ .distinctBy { it.lowercase() }
+ }
+
+ /**
+ * The groups this profile could offer [childChatRoomId] to, in room order.
+ *
+ * **The active profile's own rooms, and it cannot be anything else.** A
+ * certificate is proposed *inside* the adopting group, so a candidate has to
+ * be a room the key in the chair can already act in. Seeded from
+ * `getChatRoomListByUserPublicKey`, which is keyed by identity: since several
+ * profiles landed a device can hold rooms it must not offer here, because the
+ * key that would have to sign is not the one acting.
+ *
+ * A group cannot be offered as a parent by somebody who is not in it. That is
+ * the overlap requirement showing up as a list length, and an empty answer is
+ * a fact about this member rather than a failure -- the remedy is a person,
+ * not a button.
+ *
+ * Filtered through [refuseAdoption] itself rather than through a copy of its
+ * rules, so the picker and the manager cannot drift. The cost is one MLS
+ * decode per room, to reach `adminPubkeys`, plus two indexed reads; call it
+ * off the main thread.
+ */
+ suspend fun adoptingCandidatesFor(
+ database: MantraDatabase,
+ childChatRoomId: String,
+ userPublicKey: HexKey
+ ): List =
+ database.chatRoomDao().getChatRoomListByUserPublicKey(userPublicKey)
+ .filter { candidate ->
+ refuseAdoption(
+ database = database,
+ adoptingRoom = candidate,
+ childChatRoomId = childChatRoomId,
+ userPublicKey = userPublicKey
+ ) == null
+ }
+
+ /**
+ * Step 1: asks [adoptingRoom]'s quorum to certify an existing group as its
+ * child.
+ *
+ * The same event kind, the same six checks on the other side, and the same
+ * room it has always been proposed in -- what differs from founding is only
+ * that the child is already there, so its id and key are read off its key
+ * state rather than derived from a ceremony this device may not hold.
+ *
+ * **The roster and the name are the child's current ones**, and that is the
+ * honest value rather than a compromise. On a founding certificate they are
+ * the founding roster, frozen, because there is nothing else they could be.
+ * Here the adopting group's admins are being asked about a group that exists,
+ * and what they can weigh is who runs it now and what it is called now. They
+ * are still frozen the moment they are signed, and still never a live roster:
+ * `certifies` does not check either, for the reason it never did.
+ */
+ suspend fun proposeAdoption(
+ database: MantraDatabase,
+ adoptingRoom: LocalChatRoom,
+ childRoom: LocalChatRoom,
+ userPublicKey: HexKey
+ ): FrostSigningSession {
+ val adoptingChatRoomId = adoptingRoom.chatRoom.id
+ val childChatRoomId = childRoom.chatRoom.id
+
+ refuseAdoption(database, adoptingRoom, childChatRoomId, userPublicKey)?.let {
+ throw IllegalStateException(it)
+ }
+
+ val child = database.groupKeyStateDao().getLocalByChatRoomId(childChatRoomId)
+ ?: throw IllegalStateException("$childChatRoomId has agreed no key state to certify")
+
+ val path = child.keyState.pathIndices()
+ ?: throw IllegalStateException(
+ "$childChatRoomId names ${child.keyState.derivationPath}, which is not a path"
+ )
+
+ val childAdmins = runCatching {
+ childRoom.chatRoom.toMlsGroup()?.currentMarmotData()?.adminPubkeys
+ }.getOrNull().orEmpty()
+
+ val childName = MarmotGroupName.of(childRoom.chatRoom.subject.orEmpty().ifBlank { "a group" })
+
+ logger.i("Asking $adoptingChatRoomId to adopt $childChatRoomId as its subgroup")
+
+ return FrostSigningManager.proposeSigning(
+ database = database,
+ localChatRoom = adoptingRoom,
+ userPublicKey = userPublicKey,
+ kind = SubgroupBirthCertificateEvent.KIND,
+ tags = SubgroupBirthCertificateEvent.assembleTags(
+ subgroupChatRoomId = childChatRoomId,
+ parentChatRoomId = adoptingChatRoomId,
+ thresholdPublicKey = child.keyState.thresholdPublicKey,
+ adminPublicKeys = childAdmins,
+ name = childName,
+ path = path
+ ),
+ content = childChatRoomId
+ )
+ }
+
+ /**
+ * Step 2: asks the child's own quorum to accept [parentChatRoomId], by
+ * re-announcing its key state with that parentage added.
+ *
+ * The acceptance, and the only half of an adoption that decides anything. The
+ * certificate is an offer until this is signed, which is why
+ * `ProposedEvent`'s 30326 arm leads with the parents -- this proposal is
+ * where a member of the child answers it.
+ *
+ * **Carries the resulting set, not the addition.** A key state is replaceable
+ * and the newest wins wholesale, so a state naming only the new parent would
+ * silently drop every existing one. The current lineage is read off the row
+ * here rather than taken from the caller, and `SubgroupLineage.plus` is what
+ * makes adding an addition.
+ *
+ * Signed in the child's **own** room, which it can be because the room
+ * exists: `GroupKeyStateManager.propose`'s first branch holds, and
+ * `FrostSigningManager.signingPath` resolves off the child's own key state.
+ * Founding could not do either, which is why its key state is signed where
+ * its ceremony ran.
+ */
+ suspend fun proposeAcceptance(
+ database: MantraDatabase,
+ childRoom: LocalChatRoom,
+ userPublicKey: HexKey,
+ parentChatRoomId: HexKey
+ ): FrostSigningSession {
+ val childChatRoomId = childRoom.chatRoom.id
+
+ val child = database.groupKeyStateDao().getLocalByChatRoomId(childChatRoomId)
+ ?: throw IllegalStateException(
+ "$childChatRoomId has agreed no key state, so it has none to re-announce"
+ )
+
+ val path = child.keyState.pathIndices()
+ ?: throw IllegalStateException(
+ "$childChatRoomId names ${child.keyState.derivationPath}, which is not a path"
+ )
+
+ // The certificate this device holds for the pair. Only a member of the
+ // adopting group has one -- `recordSignedEvents` files it against that
+ // group's room -- so this is the overlap requirement again, from the
+ // other side.
+ val certificate = certificateFor(database, childChatRoomId, parentChatRoomId)
+ ?: throw IllegalStateException(
+ "$parentChatRoomId has not certified $childChatRoomId, so there is nothing to accept"
+ )
+
+ val parentage = SubgroupParentage(
+ parentChatRoomId = parentChatRoomId,
+ certificate = certificate.toEvent()
+ )
+
+ val lineage = child.lineage()?.plus(parentage) ?: SubgroupLineage.of(parentage)
+ checkNotNull(lineage) { "$childChatRoomId already names $parentChatRoomId as a parent" }
+
+ val key = FrostSigningManager.completedKey(database, childChatRoomId)
+ ?: throw IllegalStateException(
+ "This device holds no share of $childChatRoomId's key to propose with"
+ )
+
+ logger.i(
+ "Asking $childChatRoomId to accept $parentChatRoomId, making " +
+ "${lineage.size} parents in all"
+ )
+
+ return GroupKeyStateManager.propose(
+ database = database,
+ localChatRoom = childRoom,
+ userPublicKey = userPublicKey,
+ key = key,
+ path = path,
+ lineage = lineage
+ )
+ }
+
+ /**
+ * Asks the child's own quorum to stop claiming [parentChatRoomId], by
+ * re-announcing its key state without that parentage.
+ *
+ * The same act as [proposeAcceptance] with the set the other way, and the
+ * reason `SubgroupLineage.minus` exists. It **revokes nothing**: a certificate
+ * is a signature and exists forever, and what this removes is the child's half
+ * of the link -- which is the half that decides, since a lineage is what the
+ * child's quorum most recently declared.
+ *
+ * The parent's own list does not follow. `subgroupsOf` reads that group's
+ * certificates, so it will go on naming this room; a parent device that also
+ * holds this room's key state can see that it no longer names them, and one
+ * that does not cannot and must not guess.
+ */
+ suspend fun proposeDisavowal(
+ database: MantraDatabase,
+ childRoom: LocalChatRoom,
+ userPublicKey: HexKey,
+ parentChatRoomId: HexKey
+ ): FrostSigningSession {
+ val childChatRoomId = childRoom.chatRoom.id
+
+ val child = database.groupKeyStateDao().getLocalByChatRoomId(childChatRoomId)
+ ?: throw IllegalStateException(
+ "$childChatRoomId has agreed no key state, so it has none to re-announce"
+ )
+
+ val path = child.keyState.pathIndices()
+ ?: throw IllegalStateException(
+ "$childChatRoomId names ${child.keyState.derivationPath}, which is not a path"
+ )
+
+ val current = child.lineage()
+ check(current?.names(parentChatRoomId) == true) {
+ "$childChatRoomId does not name $parentChatRoomId as a parent"
+ }
+
+ val key = FrostSigningManager.completedKey(database, childChatRoomId)
+ ?: throw IllegalStateException(
+ "This device holds no share of $childChatRoomId's key to propose with"
+ )
+
+ val remaining = current.minus(parentChatRoomId)
+
+ logger.i(
+ "Asking $childChatRoomId to stop claiming $parentChatRoomId, leaving " +
+ "${remaining?.size ?: 0} parents"
+ )
+
+ return GroupKeyStateManager.propose(
+ database = database,
+ localChatRoom = childRoom,
+ userPublicKey = userPublicKey,
+ key = key,
+ path = path,
+ lineage = remaining
+ )
+ }
+
/**
* A subgroup as the parent's own record has it, plus whatever this device
* happens to hold about it.
@@ -407,18 +821,99 @@ object SubgroupManager {
.sortedByDescending { it.certificate.createdAt }
/**
- * The group a room is a subgroup of, verified, or null if it is nobody's
- * child.
+ * Every group that has certified a room as its child, verified, in canonical
+ * order -- empty for a room that is nobody's child.
*
- * Off the `GroupKeyState`, which only carries a parent whose certificate was
- * checked -- see `GroupKeyStateManager.stateFrom`. A room whose state this
- * device does not hold answers null even when it really is a subgroup, which
- * is the position a member welcomed in after the founding is in; the
- * unverified hint in the room's own description is the most they get, and it
- * must never be written here.
+ * Off `GroupKeyStateParent`, whose rows exist only where the certificate was
+ * checked against that parent's own signature -- see
+ * `GroupKeyStateManager.stateFrom`, which drops the whole state rather than
+ * keeping a lineage it could not check. So every id here is a group that
+ * really did certify this room.
+ *
+ * More than one is the ordinary case now, not a corner: a group is founded
+ * with one parent and may be adopted by another later. The order is the
+ * lineage's own and says nothing about which came first.
+ *
+ * A room whose state this device does not hold answers empty even when it
+ * really is a subgroup, which is the position a member welcomed in after the
+ * founding is in. Nothing unverified may be written here.
*/
- suspend fun parentOf(database: MantraDatabase, chatRoomId: String): HexKey? =
- database.groupKeyStateDao().getByChatRoomId(chatRoomId)?.parentChatRoomId
+ suspend fun parentsOf(database: MantraDatabase, chatRoomId: String): List =
+ 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/nostr/frost/GroupKeyStateEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/GroupKeyStateEvent.kt
index 2f2d9bd3..70ec49c9 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/GroupKeyStateEvent.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/GroupKeyStateEvent.kt
@@ -11,6 +11,7 @@ import press.mantra.compose.managers.SharedKeyDerivation
import press.mantra.compose.nostr.frost.tags.FrostDerivationPathTag
import press.mantra.compose.nostr.frost.tags.FrostKeyTag
import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent
+import press.mantra.compose.nostr.subgroup.SubgroupLineage
import press.mantra.compose.nostr.subgroup.SubgroupParentage
import press.mantra.compose.nostr.subgroup.tags.SubgroupBirthCertificateTag
import press.mantra.compose.nostr.subgroup.tags.SubgroupParentTag
@@ -110,46 +111,94 @@ object GroupKeyStateEvent {
chatRoomId: String,
dkgSessionId: String,
path: List = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH,
- parent: SubgroupParentage? = null
+ lineage: SubgroupLineage? = null
): Array> = arrayOf(
DTag.assemble(chatRoomId),
FrostKeyTag.assemble(dkgSessionId),
FrostDerivationPathTag.assemble(path)
) + (
- parent?.let {
- // Both or neither, which is why this arrives as one value rather
- // than as two nullable parameters a caller could half-fill. A state
- // carrying one without the other is dropped on read; emitting one is
- // therefore only a way to make a state nobody will believe.
- arrayOf(
+ lineage?.parents?.flatMap {
+ // Both or neither, and **adjacent**: one parent's id immediately
+ // followed by that parent's certificate, in the canonical order
+ // `SubgroupLineage` sorts them into. A state carrying one half
+ // without the other is dropped on read, so emitting one is only a
+ // way to make a state nobody will believe.
+ //
+ // The pairing is what a build predating several parents depends on.
+ // Such a reader takes the first tag of each name and checks them
+ // against each other: emitted in pairs it gets one real parent and
+ // one real certificate for it, verifies them, and files a true but
+ // partial lineage. Emitted as all the ids and then all the
+ // certificates it would pair the first id with a certificate that
+ // may belong to another parent, fail `certifies`, and drop the whole
+ // state -- losing the row that says which ceremony's share signs for
+ // this room, which is a worse outcome than not knowing about the
+ // second parent. See `SubgroupLineage`.
+ listOf(
SubgroupParentTag.assemble(it.parentChatRoomId),
SubgroupBirthCertificateTag.assemble(it.certificate)
)
- } ?: emptyArray()
+ }?.toTypedArray() ?: emptyArray()
)
/**
- * The group this one claims to be a subgroup of, or null if it claims none.
+ * The lineage the tags claim, or null if they claim none or claim one that
+ * cannot be read.
*
- * A *claim* until [SubgroupBirthCertificateEvent.certifies] says otherwise.
- * Read alongside [parseBirthCertificate] and never on its own -- see
- * `GroupKeyStateManager.stateFrom`.
+ * The two outcomes share a null for the reason every parentage reader here
+ * does: a tag that is present and unparseable looks exactly like an absent
+ * one through a parser. [claimsParentage] is what tells them apart, and
+ * `GroupKeyStateManager.stateFrom` asks it first -- a state claiming a parent
+ * in a form nothing can check is refused rather than filed as top-level.
+ *
+ * Unreadable here means any of: an odd number of halves, a tag of either
+ * name that does not parse, or the same parent named twice. Each is a lineage
+ * this device cannot state back, and completing one from the other halves
+ * would be this device deciding what the group meant.
+ *
+ * **Pairing is positional.** The *n*th `parent_group` goes with the *n*th
+ * `birth_certificate`, which is how [assembleTags] writes them and how a
+ * reader that only ever looks at the first of each reads them. Nothing is
+ * matched up by looking inside the certificates: whether the pair agrees is
+ * `SubgroupBirthCertificateEvent.certifies`' question, asked once per pair,
+ * and a mismatched pair must fail it rather than be quietly re-paired into
+ * one that passes.
*/
- fun parseParentChatRoomId(tags: Array>): HexKey? =
- tags.firstNotNullOfOrNull(SubgroupParentTag::parse)?.parentChatRoomId
+ fun parseLineage(tags: Array>): SubgroupLineage? {
+ val parentTagCount = tags.count { it.isNotEmpty() && it[0] == SubgroupParentTag.TAG_NAME }
+ val certificateTagCount =
+ tags.count { it.isNotEmpty() && it[0] == SubgroupBirthCertificateTag.TAG_NAME }
- /** The parent's signed certificate, as it signed it, or null if it carries none. */
- fun parseBirthCertificate(tags: Array>): Event? =
- tags.firstNotNullOfOrNull(SubgroupBirthCertificateTag::parse)?.certificate
+ if (parentTagCount == 0 || parentTagCount != certificateTagCount) return null
+
+ val parentChatRoomIds = tags.mapNotNull(SubgroupParentTag::parse)
+ .map { it.parentChatRoomId }
+ val certificates = tags.mapNotNull(SubgroupBirthCertificateTag::parse)
+ .map { it.certificate }
+
+ // Counted by tag name above and by successful parse here, so a half that
+ // is present and broken is told from one that was never written. Only
+ // the first would shift the pairing.
+ if (parentChatRoomIds.size != parentTagCount) return null
+ if (certificates.size != certificateTagCount) return null
+
+ return SubgroupLineage.of(
+ parentChatRoomIds.zip(certificates) { parentChatRoomId, certificate ->
+ SubgroupParentage(
+ parentChatRoomId = parentChatRoomId,
+ certificate = certificate
+ )
+ }
+ )
+ }
/**
- * Whether the tags carry either half of a parentage.
+ * Whether the tags carry any half of a parentage.
*
- * The question [parseParentChatRoomId] and [parseBirthCertificate] cannot
- * answer between them: a tag that is present and unparseable reads as absent
- * through both. A state claiming a parent in a form nothing can check must be
- * refused rather than quietly filed as an ordinary top-level group, so this
- * looks at the tag names alone.
+ * The question [parseLineage] cannot answer, because a tag that is present
+ * and unparseable reads as absent through it. A state claiming a parent in a
+ * form nothing can check must be refused rather than quietly filed as an
+ * ordinary top-level group, so this looks at the tag names alone.
*/
fun claimsParentage(tags: Array>): Boolean =
tags.any {
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEvent.kt
index 9c1f8155..0554151d 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEvent.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEvent.kt
@@ -170,15 +170,7 @@ object SubgroupBirthCertificateEvent {
subgroupChatRoomId: String,
parentChatRoomId: String
): Boolean = runCatching {
- if (event.kind != KIND) return false
-
- if (!event.content.trim().equals(subgroupChatRoomId, ignoreCase = true)) return false
-
- val addressed = parseSubgroupChatRoomId(event.tags) ?: return false
- if (!addressed.equals(subgroupChatRoomId, ignoreCase = true)) return false
-
- val named = parseParentChatRoomId(event.tags) ?: return false
- if (!named.equals(parentChatRoomId, ignoreCase = true)) return false
+ if (!addresses(event, subgroupChatRoomId, parentChatRoomId)) return false
val thresholdPublicKey = parseThresholdPublicKey(event.tags) ?: return false
val path = parsePath(event.tags) ?: return false
@@ -189,6 +181,38 @@ object SubgroupBirthCertificateEvent {
GroupKeyStateEvent.isSignedByRoom(event, parentChatRoomId)
}.getOrDefault(false)
+ /**
+ * Whether this event is *addressed to* that child by that parent -- the
+ * half of [certifies] that can be asked of a proposal nobody has signed.
+ *
+ * **Not a weaker [certifies].** It says who the event is about, not that
+ * the parent's quorum agreed to it: no derivation and no signature, so an
+ * event that merely claims the right pair passes. Nothing may decide a
+ * lineage on this.
+ *
+ * What it is for is telling one *in-flight* certificate signing from
+ * another in the same room. A parent certifies every subgroup it ever makes
+ * from one room, so the ceremony screen has several to choose between and
+ * no signature to choose by -- the proposal is what the quorum has not yet
+ * signed. The three values it reads are assembled together by
+ * [assembleTags], so they agree by construction.
+ */
+ fun addresses(
+ event: Event,
+ subgroupChatRoomId: String,
+ parentChatRoomId: String
+ ): Boolean = runCatching {
+ if (event.kind != KIND) return false
+
+ if (!event.content.trim().equals(subgroupChatRoomId, ignoreCase = true)) return false
+
+ val addressed = parseSubgroupChatRoomId(event.tags) ?: return false
+ if (!addressed.equals(subgroupChatRoomId, ignoreCase = true)) return false
+
+ val named = parseParentChatRoomId(event.tags) ?: return false
+ named.equals(parentChatRoomId, ignoreCase = true)
+ }.getOrDefault(false)
+
/**
* The name tag. A bare two-element tag with no class of its own, because
* nothing but the proposal screen and the subgroup list ever reads it and
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupLineage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupLineage.kt
new file mode 100644
index 00000000..a4d7daa0
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupLineage.kt
@@ -0,0 +1,108 @@
+package press.mantra.compose.nostr.subgroup
+
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+
+/**
+ * Every group a room is a child of, each with the signature saying so.
+ *
+ * A room's lineage is a *set*, and this is the type that makes it one. A group
+ * is founded with one parent and may be adopted by another later -- see
+ * `docs/subgroups-multiple-parents.md` -- so "the parent" was never a field so
+ * much as a set that had only ever held one thing.
+ *
+ * ### Why a type rather than a list
+ *
+ * Two invariants have to hold wherever a lineage is written, and neither is the
+ * sort a caller can be trusted to remember.
+ *
+ * **No parent appears twice.** Two entries naming one group is one parent asked
+ * twice, and a set with a repeat in it is not a set. [of] refuses rather than
+ * folding them, because the two entries carry two certificates and picking one
+ * would be this device deciding which of the parent's statements counts.
+ *
+ * **The order is canonical.** Sorted by parent id, so two devices assembling the
+ * same lineage from the same certificates emit the same bytes. That matters for
+ * one reason only, and it is a reason about other people's builds: a reader that
+ * predates several parents takes the *first* `parent_group` and the *first*
+ * `birth_certificate` off the tags and checks them against each other. Emitted
+ * in sorted pairs, such a reader files a true, partial lineage -- one parent,
+ * verified. Emitted in two unordered runs it would pair one parent's id with
+ * another's certificate, fail the check, and drop the whole state, losing the row
+ * that says which ceremony's share signs for the room. See
+ * `GroupKeyStateEvent.assembleTags`.
+ *
+ * ### Nothing here is evidence
+ *
+ * Holding one of these says only that somebody assembled it. Every certificate
+ * in it is checked by `SubgroupBirthCertificateEvent.certifies` on every read --
+ * see `GroupKeyStateManager.stateFrom`, which drops the whole state when any one
+ * of them fails. This type holds the shape; the signatures hold the trust.
+ */
+class SubgroupLineage private constructor(
+ /** The parents, in canonical order. Never empty, never repeating. */
+ val parents: List,
+) {
+ /** The parents' room ids, in the same canonical order. */
+ val parentChatRoomIds: List get() = parents.map { it.parentChatRoomId }
+
+ /** How many groups claim this room as a child. At least one. */
+ val size: Int get() = parents.size
+
+ /** Whether [parentChatRoomId] is one of them. Hex, so case does not count. */
+ fun names(parentChatRoomId: HexKey): Boolean =
+ parents.any { it.parentChatRoomId.equals(parentChatRoomId, ignoreCase = true) }
+
+ /**
+ * This lineage with [parent] added, or null if it already names that group.
+ *
+ * An adoption, as the child's quorum will sign it. It takes the whole
+ * resulting set rather than the one addition because a key state is
+ * replaceable and the newest wins *wholesale* -- so a re-announcement that
+ * carried only the new parent would silently drop the old one. Expressing the
+ * addition as an addition is what stops a caller assembling that by hand and
+ * forgetting.
+ */
+ fun plus(parent: SubgroupParentage): SubgroupLineage? =
+ if (names(parent.parentChatRoomId)) null else of(parents + parent)
+
+ /**
+ * This lineage without [parentChatRoomId], or null when it was the last one.
+ *
+ * Null is "no lineage" rather than a failure, and is what a caller passes on
+ * to say a room is nobody's child. Dropping a parent that is not named
+ * returns this lineage unchanged, since the result is the same either way.
+ *
+ * It does not revoke anything. The certificate is a signature and exists
+ * forever; what this removes is the child's half of the link, which is the
+ * half that decides. The parent's own list is built from its certificates and
+ * will go on naming the child -- see `SubgroupManager.subgroupsOf`.
+ */
+ fun minus(parentChatRoomId: HexKey): SubgroupLineage? = of(
+ parents.filterNot { it.parentChatRoomId.equals(parentChatRoomId, ignoreCase = true) }
+ )
+
+ companion object {
+ /**
+ * A lineage of [parents], or null if there are none or one repeats.
+ *
+ * Null for empty is deliberate and is the same null every parentage API
+ * here uses for "nobody's child": a room with no parents has no lineage
+ * rather than an empty one, so there is one way to say it instead of two.
+ */
+ fun of(parents: List): SubgroupLineage? {
+ if (parents.isEmpty()) return null
+
+ val distinct = parents.distinctBy { it.parentChatRoomId.lowercase() }
+ if (distinct.size != parents.size) return null
+
+ return SubgroupLineage(distinct.sortedBy { it.parentChatRoomId.lowercase() })
+ }
+
+ /**
+ * A lineage of one, which cannot fail -- a single entry has nothing to
+ * repeat. The founding case, and the shape every state signed before
+ * several parents existed is read back as.
+ */
+ fun of(parent: SubgroupParentage): SubgroupLineage = SubgroupLineage(listOf(parent))
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt
index 42794c40..319e3782 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt
@@ -70,14 +70,16 @@ interface ChatRepository {
suspend fun subgroupsOf(chatRoomId: String): List
/**
- * The group this room is a subgroup of, verified, or null if it is nobody's
- * child.
+ * Every group that has certified this room as its child, verified, in
+ * canonical order -- empty if it is nobody's child.
*
- * Off the room's `GroupKeyState`, which only carries a parent whose
- * certificate was checked. A member welcomed in after the founding holds no
- * state and gets null here even when the room really is a subgroup.
+ * Off `GroupKeyStateParent`, whose rows exist only where the certificate was
+ * checked. More than one is ordinary: a group is founded with one parent and
+ * may be adopted by another later. A member welcomed in after the founding
+ * holds no state and gets an empty list even when the room really is a
+ * subgroup.
*/
- suspend fun parentOf(chatRoomId: String): HexKey?
+ suspend fun parentsOf(chatRoomId: String): List
/**
* One event the group signed, as the group signed it, or null when this
@@ -114,6 +116,49 @@ interface ChatRepository {
coordinatorPublicKey: HexKey,
): String?
+ /**
+ * Groups that have offered to adopt this room and are not its parents yet.
+ *
+ * An offer is a certificate signed in the *other* group's room, so only a
+ * member of that group holds one -- which is the overlap an adoption needs.
+ */
+ suspend fun adoptionOffersFor(chatRoomId: String): List
+
+ /** The groups this profile could offer [chatRoomId] to, for the picker. */
+ suspend fun adoptingCandidatesFor(
+ chatRoomId: String,
+ userPublicKey: HexKey
+ ): List
+
+ /** Why [adoptingChatRoomId] cannot adopt [chatRoomId], or null when it can. */
+ suspend fun refuseAdoption(
+ chatRoomId: String,
+ adoptingChatRoomId: String,
+ userPublicKey: HexKey
+ ): String?
+
+ /**
+ * Asks [adoptingChatRoomId]'s quorum to certify this room as its child.
+ *
+ * The offer, and only that: the child's own quorum answers it with
+ * [acceptParent], and nothing is adopted until it does.
+ */
+ suspend fun proposeAdoption(
+ chatRoomId: String,
+ adoptingChatRoomId: String,
+ userPublicKey: HexKey
+ ): Boolean
+
+ /**
+ * Asks this room's quorum to accept [parentChatRoomId], by re-announcing its
+ * key state with that parentage added to whatever it already claims.
+ */
+ suspend fun acceptParent(
+ chatRoomId: String,
+ parentChatRoomId: HexKey,
+ userPublicKey: HexKey
+ ): Boolean
+
suspend fun getAllParticipantsWithPubKey(publicKey: HexKey): List
/**
@@ -203,7 +248,6 @@ interface ChatRepository {
userPublicKey: HexKey,
keyPair: KeyPair,
path: List = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH,
- parentChatRoomId: HexKey? = null,
): MarmotGroupCreation.Outcome
/**
@@ -279,7 +323,7 @@ interface ChatRepository {
override suspend fun subgroupsOf(chatRoomId: String): List =
emptyList()
- override suspend fun parentOf(chatRoomId: String): HexKey? = null
+ override suspend fun parentsOf(chatRoomId: String): List = emptyList()
override suspend fun groupSignedEvent(
chatRoomId: String,
@@ -306,6 +350,31 @@ interface ChatRepository {
return null
}
+ override suspend fun adoptionOffersFor(chatRoomId: String): List = emptyList()
+
+ override suspend fun adoptingCandidatesFor(
+ chatRoomId: String,
+ userPublicKey: HexKey
+ ): List = emptyList()
+
+ override suspend fun refuseAdoption(
+ chatRoomId: String,
+ adoptingChatRoomId: String,
+ userPublicKey: HexKey
+ ): String? = "No database"
+
+ override suspend fun proposeAdoption(
+ chatRoomId: String,
+ adoptingChatRoomId: String,
+ userPublicKey: HexKey
+ ): Boolean = false
+
+ override suspend fun acceptParent(
+ chatRoomId: String,
+ parentChatRoomId: HexKey,
+ userPublicKey: HexKey
+ ): Boolean = false
+
override suspend fun getAllParticipantsWithPubKey(publicKey: HexKey): List {
TODO("Not yet implemented")
}
@@ -386,7 +455,6 @@ interface ChatRepository {
userPublicKey: HexKey,
keyPair: KeyPair,
path: List,
- parentChatRoomId: HexKey?,
): MarmotGroupCreation.Outcome =
MarmotGroupCreation.Outcome.Failed("No database")
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt
index cb4da4eb..c61a9e85 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt
@@ -3,7 +3,7 @@ package press.mantra.compose.repository
import press.mantra.compose.database.model.DkgParticipantMessage
import press.mantra.compose.database.model.DkgSession
import press.mantra.compose.database.model.FrostSigningSession
-import press.mantra.compose.database.model.GroupKeyState
+import press.mantra.compose.database.model.intermdiate.LocalGroupKeyState
import press.mantra.compose.database.model.GroupSignedEvent
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.database.model.intermdiate.LocalFrostSigningSession
@@ -65,6 +65,19 @@ interface DkgRepository {
suspend fun getLatestSessionForChatRoom(chatRoomId: String): DkgSession?
+ /**
+ * The id of the room a ceremony's key derives, or null while it has no key.
+ *
+ * The one name a ceremony and the room it makes have in common, and the only
+ * way to tell two of a parent's subgroups apart in anything written about
+ * the room rather than about the ceremony -- a birth certificate names the
+ * child, not the ceremony that produced it.
+ *
+ * Null is the ordinary answer rather than a failure: a ceremony has no key
+ * until it finishes, and every caller here is watching from before that.
+ */
+ suspend fun roomDerivedBy(dkgSessionId: String): String?
+
/**
* Opens a ritual in [localChatRoom], making this device the coordinator.
*
@@ -149,10 +162,10 @@ interface DkgRepository {
* a finished subgroup with its key state signed, its certificate signed, and
* no way to create the room -- the lookup found no ceremony at all.
*/
- fun observeSignedGroupKeyState(dkgSessionId: String): Flow
+ fun observeSignedGroupKeyState(dkgSessionId: String): Flow
/** Files the state the group signed for a room that now exists. */
- suspend fun adoptGroupKeyState(chatRoomId: String): GroupKeyState?
+ suspend fun adoptGroupKeyState(chatRoomId: String): LocalGroupKeyState?
/**
* Asks the parent's quorum to certify the subgroup this ceremony produced.
@@ -213,6 +226,8 @@ interface DkgRepository {
override suspend fun getLatestSessionForChatRoom(chatRoomId: String): DkgSession? = null
+ override suspend fun roomDerivedBy(dkgSessionId: String): String? = null
+
override suspend fun proposeRitual(
localChatRoom: LocalChatRoom,
userPublicKey: HexKey,
@@ -242,10 +257,10 @@ interface DkgRepository {
chatRoomId: String
): Flow> = flowOf(emptyList())
- override fun observeSignedGroupKeyState(dkgSessionId: String): Flow =
+ override fun observeSignedGroupKeyState(dkgSessionId: String): Flow =
flowOf(null)
- override suspend fun adoptGroupKeyState(chatRoomId: String): GroupKeyState? = null
+ override suspend fun adoptGroupKeyState(chatRoomId: String): LocalGroupKeyState? = null
override suspend fun proposeBirthCertificate(
parentChatRoomId: String,
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 0513166b..136ab20c 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt
@@ -210,13 +210,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/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt
index f04cd668..d5af2cba 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt
@@ -70,6 +70,7 @@ import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendin
import press.mantra.compose.ui.composable.navigation.routes.ProfilePreviewRoute
import press.mantra.compose.ui.composable.navigation.routes.Route
import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute
+import press.mantra.compose.ui.composable.navigation.routes.DkgRitualListRoute
import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute
import press.mantra.compose.ui.composable.navigation.routes.SearchMemberToAddToChatRoomRoute
import press.mantra.compose.extensions.toFormattedTimeAndDateString
@@ -90,12 +91,18 @@ import press.mantra.compose.ui.composable.navigation.routes.ProjectDetailRoute
import press.mantra.compose.ui.theme.spacing
import mantra.composeapp.generated.resources.run_by_members
import mantra.composeapp.generated.resources.parent_group
+import mantra.composeapp.generated.resources.accept_as_a_parent
+import mantra.composeapp.generated.resources.add_a_parent_group
+import mantra.composeapp.generated.resources.a_group_is_a_subgroup_once_its_own_quorum_says_so
+import mantra.composeapp.generated.resources.asked_this_group_to_accept_that_parent
+import mantra.composeapp.generated.resources.s_has_offered_to_adopt_this_group
import mantra.composeapp.generated.resources.a_subgroup
import mantra.composeapp.generated.resources.created_you_are_not_a_member
import mantra.composeapp.generated.resources.certified_not_yet_created
import mantra.composeapp.generated.resources.no_subgroups_have_been_made_by_this_group
import mantra.composeapp.generated.resources.add_subgroup
import mantra.composeapp.generated.resources.subgroups
+import press.mantra.compose.ui.composable.navigation.routes.SelectAdoptingGroupRoute
import press.mantra.compose.ui.composable.navigation.routes.SelectSubgroupAdminsRoute
import press.mantra.compose.ui.composable.navigation.routes.NostrEventDetailRoute
import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute
@@ -119,6 +126,7 @@ import mantra.composeapp.generated.resources.no_projects_exists_in_this_group
import mantra.composeapp.generated.resources.projects
import mantra.composeapp.generated.resources.proposals
import mantra.composeapp.generated.resources.reindex_events
+import mantra.composeapp.generated.resources.key_ceremonies
import mantra.composeapp.generated.resources.shared_key
import mantra.composeapp.generated.resources.signing_key
import mantra.composeapp.generated.resources.the_key_this_room_signs_as
@@ -168,6 +176,13 @@ fun ChatRoomDetailScreen(
)
)
+ // Both read here because both are composable and an onClick lambda is not.
+ // The notifier takes the caller's scope on purpose: an acceptance is shown
+ // while the screen reloads around it, and a message launched in a departing
+ // composable's scope would be cancelled with it.
+ val notify = rememberNotifier(rememberCoroutineScope())
+ val acceptedParent = stringResource(Res.string.asked_this_group_to_accept_that_parent)
+
ScreenStateTransition(chatRoomDetailViewModel.chatRoomDetailUIState) { uiState ->
when (val chatRoomDetailUIState = uiState) {
is ChatRoomDetailUIState.Error -> {
@@ -261,8 +276,14 @@ fun ChatRoomDetailScreen(
// nobody's child, and absent -- rather than guessed --
// on a subgroup this device holds no key state for,
// since an unverified lineage is not one worth showing.
- chatRoomDetailUIState.parentChatRoomId?.let { parentChatRoomId ->
- item {
+ //
+ // One card per parent, because a room can have more
+ // than one: founded with one and adopted by another
+ // later. They are a set rather than a path -- neither
+ // is "the" parent and the order says nothing about
+ // which came first -- so each gets an equal row.
+ chatRoomDetailUIState.parentChatRoomIds.forEach { parentChatRoomId ->
+ item(key = "parent-$parentChatRoomId") {
Card(
modifier = Modifier.fillMaxWidth(),
onClick = {
@@ -306,6 +327,99 @@ fun ChatRoomDetailScreen(
}
}
+ // An offer, and it has to read like one. The other
+ // group's quorum has signed; this group has not, and
+ // until it does the lineage is unchanged. Rendered as
+ // a fact about the lineage it would be the consent gap
+ // reopened from the other end.
+ chatRoomDetailUIState.adoptionOffers.forEach { offeringChatRoomId ->
+ item(key = "offer-$offeringChatRoomId") {
+ Card(modifier = Modifier.fillMaxWidth()) {
+ ListItem(
+ leadingContent = {
+ Icon(
+ Icons.Default.AccountTree,
+ contentDescription = Decorative
+ )
+ },
+ headlineContent = {
+ Text(
+ text = stringResource(
+ Res.string.s_has_offered_to_adopt_this_group,
+ offeringChatRoomId.take(16)
+ )
+ )
+ },
+ supportingContent = {
+ Text(
+ text = stringResource(
+ Res.string
+ .a_group_is_a_subgroup_once_its_own_quorum_says_so
+ ),
+ style = MaterialTheme.typography.bodySmall
+ )
+ },
+ trailingContent = {
+ // Offered to whoever opens the
+ // screen and can act, not only to
+ // the member who asked -- every
+ // step after the ceremony is open
+ // to any admin holding a share.
+ if (chatRoomDetailUIState.canAddParent) {
+ TextButton(
+ onClick = {
+ chatRoomDetailViewModel.acceptParent(
+ offeringChatRoomId
+ ) { message ->
+ notify(message ?: acceptedParent)
+ }
+ }
+ ) {
+ Text(
+ stringResource(
+ Res.string.accept_as_a_parent
+ )
+ )
+ }
+ }
+ }
+ )
+ }
+ }
+ }
+
+ // Hidden rather than disabled, the way the subgroup
+ // entry is: a room with no key state has neither half
+ // of an adoption to work with, and a device with no
+ // share cannot sign either of them.
+ if (chatRoomDetailUIState.canAddParent) {
+ item {
+ TextButton(
+ onClick = {
+ onNavigateToRoute.invoke(
+ SelectAdoptingGroupRoute(
+ activeUserPublicKey = activeUserPublicKey,
+ chatRoomId = chatRoomId,
+ relayHint = relayHint
+ )
+ )
+ }
+ ) {
+ Icon(
+ Icons.Default.AccountTree,
+ contentDescription = Decorative
+ )
+
+ Spacer(
+ modifier = Modifier
+ .width(MaterialTheme.spacing.relatedGap)
+ )
+
+ Text(stringResource(Res.string.add_a_parent_group))
+ }
+ }
+ }
+
item {
chatRoomDetailUIState.localChatRoom.chatRoom.description?.let {
Text(
@@ -671,6 +785,39 @@ fun ChatRoomDetailScreen(
}
}
+ // Every room, including the Marmot ones the `mlsGroupState`
+ // check above keeps "Shared key" out of. That button offers
+ // a room a key of its own, which only a NIP-17 room can
+ // want; this one lists the ceremonies a room *holds*, and a
+ // parent's Marmot room holds one per subgroup the group has
+ // ever made. Until this there was no way to reach the second
+ // of them except by scrolling the transcript to its request
+ // line, and no way at all to reach one whose line had never
+ // arrived.
+ item {
+ TextButton(
+ onClick = {
+ onNavigateToRoute.invoke(
+ DkgRitualListRoute(
+ activeUserPublicKey = activeUserPublicKey,
+ chatRoomId = chatRoomId
+ )
+ )
+ }
+ ) {
+ Icon(
+ Icons.Default.Key,
+ contentDescription = "Key ceremonies"
+ )
+
+ Spacer(
+ modifier = Modifier.width(MaterialTheme.spacing.space125)
+ )
+
+ Text(stringResource(Res.string.key_ceremonies))
+ }
+ }
+
// Every room, not only the one that holds the key. Most
// of what a group signs it signs in its Marmot room --
// its subgroups' certificates and key states included --
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt
index e678a623..e5b842ae 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt
@@ -49,6 +49,7 @@ import press.mantra.compose.repository.NostrRepository
import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute
import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute
import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute
+import press.mantra.compose.ui.composable.navigation.routes.DkgRitualListRoute
import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute
import press.mantra.compose.ui.composable.navigation.routes.Route
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
@@ -176,12 +177,37 @@ fun ChatRoomMessagingScreen(
ChatTranscript(
viewModel = chatMessageListViewModel,
onOpenSharedKey = { dkgSessionId ->
+ // The same two reasons the signing lines open
+ // the queue instead of one proposal. A line
+ // written before chat rows named their ceremony
+ // cannot say which it is about, and the room may
+ // hold several -- the list is the honest answer
+ // there, showing all of them with their own
+ // state rather than guessing at the newest,
+ // which since subgroups moved into the parent's
+ // room is routinely the finished one.
+ //
+ // The other is that the ceremony they tapped is
+ // not the only one waiting on them, in which
+ // case it is not the whole of what is being
+ // asked and a screen showing only it would say
+ // it was.
onNavigateToRoute.invoke(
- DkgRitualRoute(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- dkgSessionId = dkgSessionId
- )
+ if (dkgSessionId == null ||
+ chatMessageListViewModel
+ .hidesOtherCeremonies(dkgSessionId)
+ ) {
+ DkgRitualListRoute(
+ activeUserPublicKey = activeUserPublicKey,
+ chatRoomId = chatRoomId
+ )
+ } else {
+ DkgRitualRoute(
+ activeUserPublicKey = activeUserPublicKey,
+ chatRoomId = chatRoomId,
+ dkgSessionId = dkgSessionId
+ )
+ }
)
},
onOpenSigning = { sessionId ->
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgApprovalScaffold.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgApprovalScaffold.kt
index cc358aa5..d7513e87 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgApprovalScaffold.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgApprovalScaffold.kt
@@ -22,6 +22,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -100,7 +101,7 @@ internal fun DkgApprovalScaffold(
)
}
) { padding ->
- when (val state = dkgRitualViewModel.dkgRitualUIState) {
+ when (val state = dkgRitualViewModel.dkgRitualUIState.collectAsState().value) {
is DkgRitualUIState.Loading -> Column(
modifier = Modifier.fillMaxWidth().padding(padding).readableContent().padding(MaterialTheme.spacing.space250),
horizontalAlignment = Alignment.CenterHorizontally
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualListScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualListScreen.kt
new file mode 100644
index 00000000..aa7121b6
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualListScreen.kt
@@ -0,0 +1,504 @@
+package press.mantra.compose.ui.composable
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.filled.ChevronRight
+import androidx.compose.material.icons.filled.ErrorOutline
+import androidx.compose.material.icons.filled.HourglassEmpty
+import androidx.compose.material.icons.filled.Key
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import mantra.composeapp.generated.resources.Res
+import mantra.composeapp.generated.resources.a_subgroup_named_s
+import mantra.composeapp.generated.resources.a_subgroups_key
+import mantra.composeapp.generated.resources.abandoned_no_key_was_created
+import mantra.composeapp.generated.resources.check_the_combined_result
+import mantra.composeapp.generated.resources.everything_else
+import mantra.composeapp.generated.resources.join_the_ceremony_by_publishing_your_key
+import mantra.composeapp.generated.resources.key_ceremonies
+import mantra.composeapp.generated.resources.open
+import mantra.composeapp.generated.resources.review
+import mantra.composeapp.generated.resources.s_of_s_members_will_sign_with_it
+import mantra.composeapp.generated.resources.send_your_contribution_to_the_key
+import mantra.composeapp.generated.resources.set_up_a_shared_key
+import mantra.composeapp.generated.resources.the_group_has_a_key_from_this_ceremony
+import mantra.composeapp.generated.resources.the_group_has_its_key_finish_setting_it_up
+import mantra.composeapp.generated.resources.the_groups_shared_key
+import mantra.composeapp.generated.resources.this_group_has_held_no_key_ceremony
+import mantra.composeapp.generated.resources.waiting_for_everyone_to_commit_their_part
+import mantra.composeapp.generated.resources.waiting_for_everyone_to_confirm_the_result
+import mantra.composeapp.generated.resources.waiting_for_members_to_join
+import mantra.composeapp.generated.resources.waiting_for_you
+import org.jetbrains.compose.resources.stringResource
+import press.mantra.compose.database.model.ChatRoom
+import press.mantra.compose.database.model.DkgSession
+import press.mantra.compose.database.model.intermdiate.LocalChatRoom
+import press.mantra.compose.database.model.types.DkgApprovalStep
+import press.mantra.compose.database.model.types.DkgRitualStage
+import press.mantra.compose.extensions.toFormattedTimeAndDateString
+import press.mantra.compose.repository.ChatRepository
+import press.mantra.compose.repository.DkgRepository
+import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute
+import press.mantra.compose.ui.composable.navigation.routes.Route
+import press.mantra.compose.ui.composable.widgets.Decorative
+import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
+import press.mantra.compose.ui.composable.widgets.NavigateBackButton
+import press.mantra.compose.ui.theme.ConformancePreviews
+import press.mantra.compose.ui.theme.MantraTheme
+import press.mantra.compose.ui.theme.readableContent
+import press.mantra.compose.ui.theme.spacing
+import press.mantra.compose.ui.view.model.DkgRitualListViewModel
+import press.mantra.compose.ui.view.state.DkgRitualListUIState
+
+/**
+ * Every key ceremony a room holds.
+ *
+ * The transcript shows a ceremony as it happens, in among everything else that
+ * was said, and the standing notice above the composer says when one is waiting
+ * on the reader. Neither can say *which*, once there are two -- and since a
+ * subgroup's ChillDKG moved into its parent's room there are routinely two: the
+ * group's own, long finished, and whatever subgroup is being made this week.
+ *
+ * So: one row per ceremony, each carrying its own state, the finished ones kept
+ * rather than dropped. The ones still waiting on the reader come first, because
+ * those are the only ones with anything to do in them; the rest are the record
+ * of how this group came to hold the keys it holds, which is the other half of
+ * what this screen is for.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun DkgRitualListScreen(
+ activeUserPublicKey: HexKey,
+ chatRoomId: String,
+ initialDkgRitualListUIState: DkgRitualListUIState = DkgRitualListUIState.Loading,
+ chatRepository: ChatRepository,
+ dkgRepository: DkgRepository,
+ onNavigateBack: () -> Unit,
+ onNavigateToRoute: (Route) -> Unit,
+) {
+ val dkgRitualListViewModel: DkgRitualListViewModel = viewModel(
+ factory = DkgRitualListViewModel.factory(
+ chatRoomId = chatRoomId,
+ activeUserPublicKey = activeUserPublicKey,
+ initialDkgRitualListUIState = initialDkgRitualListUIState,
+ chatRepository = chatRepository,
+ dkgRepository = dkgRepository
+ )
+ )
+
+ LaunchedEffect(true) {
+ if (initialDkgRitualListUIState == DkgRitualListUIState.Loading) {
+ dkgRitualListViewModel.initiate()
+ }
+ }
+
+ Scaffold(
+ snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ text = stringResource(Res.string.key_ceremonies),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ },
+ navigationIcon = { NavigateBackButton(onNavigateBack) }
+ )
+ }
+ ) { padding ->
+ when (val state = dkgRitualListViewModel.dkgRitualListUIState.collectAsState().value) {
+ is DkgRitualListUIState.Loading -> Column(
+ modifier = Modifier.fillMaxWidth().padding(padding).readableContent()
+ .padding(MaterialTheme.spacing.space250),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
+ CircularProgressIndicator()
+ }
+
+ is DkgRitualListUIState.Error -> Column(
+ modifier = Modifier.fillMaxWidth().padding(padding).readableContent()
+ .padding(MaterialTheme.spacing.space250),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
+ Text(text = state.message, textAlign = TextAlign.Center)
+ }
+
+ is DkgRitualListUIState.Loaded -> {
+ val open: (DkgRitualListUIState.Ceremony) -> Unit = { ceremony ->
+ onNavigateToRoute.invoke(
+ DkgRitualRoute(
+ activeUserPublicKey = activeUserPublicKey,
+ chatRoomId = chatRoomId,
+ dkgSessionId = ceremony.session.id
+ )
+ )
+ }
+
+ LazyColumn(
+ modifier = Modifier.padding(padding).readableContent().fillMaxSize()
+ .padding(MaterialTheme.spacing.space250),
+ verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
+ ) {
+ if (state.ceremonies.isEmpty()) {
+ item {
+ Column(
+ modifier = Modifier.fillMaxWidth()
+ .padding(top = MaterialTheme.spacing.space600),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(
+ MaterialTheme.spacing.sectionGap
+ )
+ ) {
+ Text(
+ text = stringResource(
+ Res.string.this_group_has_held_no_key_ceremony
+ ),
+ textAlign = TextAlign.Center
+ )
+
+ // The way out of an empty list, rather than a
+ // dead end that sends a member back to the
+ // group's details to find the other door. The
+ // ceremony screen decides whether the group is
+ // in a position to hold one; this only offers
+ // to ask it.
+ FilledTonalButton(onClick = {
+ onNavigateToRoute.invoke(
+ DkgRitualRoute(
+ activeUserPublicKey = activeUserPublicKey,
+ chatRoomId = chatRoomId
+ )
+ )
+ }) {
+ Icon(Icons.Default.Key, contentDescription = Decorative)
+
+ Spacer(
+ modifier = Modifier.width(
+ MaterialTheme.spacing.relatedGap
+ )
+ )
+
+ Text(stringResource(Res.string.set_up_a_shared_key))
+ }
+ }
+ }
+ }
+
+ if (state.waitingForYou.isNotEmpty()) {
+ item {
+ Text(
+ text = stringResource(Res.string.waiting_for_you),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+
+ items(
+ items = state.waitingForYou,
+ key = { it.session.id }
+ ) { ceremony ->
+ CeremonyCard(ceremony = ceremony, onClick = { open(ceremony) })
+ }
+ }
+
+ if (state.rest.isNotEmpty()) {
+ // Headed only when there is a section above to tell it
+ // apart from; on its own it is the whole screen, which
+ // the title already says.
+ if (state.waitingForYou.isNotEmpty()) {
+ item {
+ Text(
+ text = stringResource(Res.string.everything_else),
+ style = MaterialTheme.typography.labelMedium
+ )
+ }
+ }
+
+ items(
+ items = state.rest,
+ key = { it.session.id }
+ ) { ceremony ->
+ CeremonyCard(ceremony = ceremony, onClick = { open(ceremony) })
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+/**
+ * One ceremony, named after the key it is making.
+ *
+ * What a ceremony is *for* is the first thing to say about it, because a room
+ * holding two holds them for different groups: one is this room's own key and
+ * the other belongs to a subgroup that does not exist yet. A row that only said
+ * how far along it was would leave a member guessing which of the two they were
+ * looking at.
+ */
+@Composable
+private fun CeremonyCard(
+ ceremony: DkgRitualListUIState.Ceremony,
+ onClick: () -> Unit,
+) {
+ val session = ceremony.session
+
+ // Every colour inside this card is derived from the card rather than reached
+ // for independently, because ListItem does not inherit LocalContentColor --
+ // its headline comes from `onSurface` whatever the container is. See
+ // ProposalListScreen, where that combination drew #1B1B1B on #1B1B1B.
+ val cardColors = if (ceremony.awaitsYou) {
+ CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer
+ )
+ } else {
+ CardDefaults.cardColors()
+ }
+ val cardContentColor = cardColors.contentColor
+
+ // A subgroup is named by its proposal, and a member who did not open it may
+ // be looking at the ceremony before that name has arrived -- hence the third
+ // branch, which still says which of the two kinds of key this is.
+ val subject = session.subject
+
+ val label = when {
+ !ceremony.isSubgroup -> stringResource(Res.string.the_groups_shared_key)
+ subject != null -> stringResource(Res.string.a_subgroup_named_s, subject)
+ else -> stringResource(Res.string.a_subgroups_key)
+ }
+
+ Card(onClick = onClick, colors = cardColors) {
+ ListItem(
+ colors = ListItemDefaults.colors(
+ containerColor = Color.Transparent,
+ headlineColor = cardContentColor,
+ overlineColor = cardContentColor,
+ supportingColor = cardContentColor,
+ leadingIconColor = cardContentColor,
+ trailingIconColor = cardContentColor,
+ ),
+ leadingContent = {
+ Icon(
+ imageVector = when {
+ ceremony.awaitsYou -> Icons.Default.Key
+ session.stage == DkgRitualStage.COMPLETE -> Icons.Default.CheckCircle
+ session.stage == DkgRitualStage.FAILED -> Icons.Default.ErrorOutline
+ else -> Icons.Default.HourglassEmpty
+ },
+ // Carries the stage on its own, and carries it alone on the
+ // highlighted card where the failure state has no colour to
+ // spare.
+ contentDescription = when {
+ ceremony.awaitsYou -> "Waiting on you"
+ session.stage == DkgRitualStage.COMPLETE -> "Finished"
+ session.stage == DkgRitualStage.FAILED -> "Abandoned"
+ else -> "Waiting on others"
+ },
+ tint = when {
+ // `error` is 2.67:1 on the highlighted card, so there the
+ // failure is carried by the icon's shape and by the status
+ // line rather than by colour.
+ session.stage == DkgRitualStage.FAILED &&
+ !ceremony.awaitsYou -> MaterialTheme.colorScheme.error
+ else -> cardContentColor
+ }
+ )
+ },
+ trailingContent = {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ // The same word the transcript's notice uses for the same
+ // thing, so a member who came here looking for the card they
+ // tapped finds it saying what it said there.
+ Text(
+ text = if (ceremony.awaitsYou) {
+ stringResource(Res.string.review)
+ } else {
+ stringResource(Res.string.open)
+ },
+ style = MaterialTheme.typography.labelLarge,
+ color = cardContentColor
+ )
+
+ Icon(
+ Icons.Default.ChevronRight,
+ contentDescription = "Open this ceremony"
+ )
+ }
+ },
+ overlineContent = { Text(text = label) },
+ headlineContent = {
+ Text(
+ text = statusOf(ceremony),
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis
+ )
+ },
+ supportingContent = {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space25)
+ ) {
+ // The quorum a ceremony settles is fixed by it and cannot be
+ // changed afterwards, so it belongs on the row rather than
+ // only on the screen behind it.
+ Text(
+ text = stringResource(
+ Res.string.s_of_s_members_will_sign_with_it,
+ session.threshold,
+ session.participantCount
+ )
+ )
+
+ Text(
+ text = "${ceremony.coordinatorName} · " +
+ session.createdAt.toFormattedTimeAndDateString(),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ )
+ }
+}
+
+/**
+ * Where a ceremony has got to, in a line.
+ *
+ * Shorter than the ceremony screen's ladder, and it has to be: this is one row
+ * of a list somebody is scanning, not the page they are acting on. What it must
+ * never do is call a ceremony finished when it is not, or waiting when there is
+ * nothing left to wait for.
+ *
+ * What it owes this member outranks the stage, in the words the approval screens
+ * use for the same step, so nobody is asked twice in two vocabularies.
+ */
+@Composable
+private fun statusOf(ceremony: DkgRitualListUIState.Ceremony): String {
+ ceremony.pendingApproval?.let {
+ return when (it) {
+ DkgApprovalStep.HOST_KEY ->
+ stringResource(Res.string.join_the_ceremony_by_publishing_your_key)
+ DkgApprovalStep.ROUND_1 ->
+ stringResource(Res.string.send_your_contribution_to_the_key)
+ DkgApprovalStep.ROUND_2 ->
+ stringResource(Res.string.check_the_combined_result)
+ }
+ }
+
+ if (ceremony.roomAwaitingCreation != null) {
+ return stringResource(Res.string.the_group_has_its_key_finish_setting_it_up)
+ }
+
+ return when (ceremony.session.stage) {
+ DkgRitualStage.COLLECTING_HOST_KEYS ->
+ stringResource(Res.string.waiting_for_members_to_join)
+
+ DkgRitualStage.COLLECTING_ROUND_1 ->
+ stringResource(Res.string.waiting_for_everyone_to_commit_their_part)
+
+ DkgRitualStage.COLLECTING_ROUND_2 ->
+ stringResource(Res.string.waiting_for_everyone_to_confirm_the_result)
+
+ DkgRitualStage.COMPLETE ->
+ stringResource(Res.string.the_group_has_a_key_from_this_ceremony)
+
+ // The reason is the whole content of a failure -- "a member refused" and
+ // "the group could not agree" are different things to have happened -- so
+ // it stands in for the bare word whenever there is one.
+ DkgRitualStage.FAILED ->
+ ceremony.session.failureReason
+ ?: stringResource(Res.string.abandoned_no_key_was_created)
+ }
+}
+
+@ConformancePreviews
+@Composable
+private fun DkgRitualListScreenPreview() {
+ val session = DkgSession(
+ id = "sessionId",
+ chatRoomId = "chatRoomId",
+ coordinatorPublicKey = "c".repeat(64),
+ userPublicKey = "u".repeat(64),
+ threshold = 2,
+ participantCount = 3,
+ hostPublicKey = "h".repeat(64),
+ round1Random = "1".repeat(64),
+ round2AuxRandom = "2".repeat(64),
+ )
+
+ MantraTheme {
+ Surface(modifier = Modifier.fillMaxSize()) {
+ DkgRitualListScreen(
+ activeUserPublicKey = "u".repeat(64),
+ chatRoomId = "chatRoomId",
+ initialDkgRitualListUIState = DkgRitualListUIState.Loaded(
+ localChatRoom = LocalChatRoom(
+ chatRoom = ChatRoom(
+ id = "chatRoomId",
+ userPublicKey = "u".repeat(64),
+ subject = "#Ekklesia",
+ description = null,
+ initialGiftWrapPayloadId = "sdfaer",
+ mlsGroupState = null
+ )
+ ),
+ ceremonies = listOf(
+ DkgRitualListUIState.Ceremony(
+ session = session.copy(
+ id = "subgroupSessionId",
+ parentChatRoomId = "parentChatRoomId",
+ subject = "Translators",
+ stage = DkgRitualStage.COLLECTING_ROUND_1
+ ),
+ coordinatorName = "Ada",
+ pendingApproval = DkgApprovalStep.ROUND_1
+ ),
+ DkgRitualListUIState.Ceremony(
+ session = session.copy(stage = DkgRitualStage.COMPLETE),
+ coordinatorName = "You"
+ )
+ )
+ ),
+ chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
+ dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY,
+ onNavigateBack = {},
+ onNavigateToRoute = {}
+ )
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt
index f669285c..3ea26e01 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt
@@ -37,6 +37,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -154,7 +155,7 @@ fun DkgRitualScreen(
)
)
- ScreenStateTransition(dkgRitualViewModel.dkgRitualUIState) { uiState ->
+ ScreenStateTransition(dkgRitualViewModel.dkgRitualUIState.collectAsState().value) { uiState ->
when (val dkgRitualUIState = uiState) {
is DkgRitualUIState.Error -> {
Column(
@@ -168,7 +169,7 @@ fun DkgRitualScreen(
is DkgRitualUIState.Loaded -> {
val session = dkgRitualUIState.session
val participantCount = dkgRitualUIState.participantCount
- val canStartRitual = dkgRitualViewModel.canStartRitual()
+ val canStartRitual = dkgRitualViewModel.canStartRitual(dkgRitualUIState)
val isActionPending = dkgRitualViewModel.isActionPending.value
Scaffold(
@@ -286,7 +287,7 @@ fun DkgRitualScreen(
QuorumStepper(
threshold = dkgRitualViewModel.threshold.value,
participantCount = participantCount,
- quorumRange = dkgRitualViewModel.quorumRange(),
+ quorumRange = dkgRitualViewModel.quorumRange(dkgRitualUIState),
onThresholdChange = dkgRitualViewModel::setThreshold
)
} else {
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectAdoptingGroupScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectAdoptingGroupScreen.kt
new file mode 100644
index 00000000..16313ef2
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectAdoptingGroupScreen.kt
@@ -0,0 +1,195 @@
+package press.mantra.compose.ui.composable
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.AccountTree
+import androidx.compose.material.icons.filled.ChevronRight
+import androidx.compose.material3.Card
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import kotlinx.coroutines.launch
+import mantra.composeapp.generated.resources.Res
+import mantra.composeapp.generated.resources.asked_that_group_to_adopt_this_one
+import mantra.composeapp.generated.resources.no_group_here_can_adopt_this_one
+import mantra.composeapp.generated.resources.which_group_should_adopt_this_one
+import mantra.composeapp.generated.resources.who_adopts_this_group
+import org.jetbrains.compose.resources.stringResource
+import press.mantra.compose.repository.ChatRepository
+import press.mantra.compose.ui.composable.widgets.Decorative
+import press.mantra.compose.ui.composable.widgets.EmptyState
+import press.mantra.compose.ui.composable.widgets.ErrorState
+import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
+import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
+import press.mantra.compose.ui.composable.widgets.NavigateBackButton
+import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
+import press.mantra.compose.ui.theme.readableContent
+import press.mantra.compose.ui.theme.spacing
+import press.mantra.compose.ui.view.model.SelectAdoptingGroupViewModel
+import press.mantra.compose.ui.view.state.SelectAdoptingGroupUIState
+
+/**
+ * Which group to ask to adopt this one.
+ *
+ * Tapping a row asks that group's quorum to certify this room as its child. That
+ * is an **offer** and nothing more: this room becomes their subgroup only once
+ * its own quorum re-announces its key state naming them, which is a card back on
+ * the room's own screen. So this hands back rather than going anywhere new, and
+ * the snackbar says what was asked rather than what was done.
+ *
+ * The list is the profile's own rooms -- see `SelectAdoptingGroupViewModel` for
+ * why it can be nothing else -- so empty is an ordinary answer and the empty
+ * state explains it rather than apologising.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SelectAdoptingGroupScreen(
+ activeUserPublicKey: HexKey,
+ chatRoomId: String,
+ initialUIState: SelectAdoptingGroupUIState = SelectAdoptingGroupUIState.Loading,
+ chatRepository: ChatRepository,
+ onNavigateBack: () -> Unit,
+) {
+ val viewModel: SelectAdoptingGroupViewModel = viewModel(
+ factory = SelectAdoptingGroupViewModel.factory(
+ activeUserPublicKey = activeUserPublicKey,
+ chatRoomId = chatRoomId,
+ initialUIState = initialUIState,
+ chatRepository = chatRepository
+ )
+ )
+
+ LaunchedEffect(chatRoomId) { viewModel.initiate() }
+
+ val snackbarHostState = LocalSnackbarHostState.current
+ val scope = rememberCoroutineScope()
+
+ // Read outside the handler: both of these are composable and an onClick
+ // lambda is not. The notifier takes the caller's scope because the message is
+ // shown as this screen navigates away, and one launched in the departing
+ // composable's scope would be cancelled with it.
+ val asked = stringResource(Res.string.asked_that_group_to_adopt_this_one)
+
+ ScreenStateTransition(viewModel.uiState) { uiState ->
+ when (val state = uiState) {
+ is SelectAdoptingGroupUIState.Error -> ErrorState(
+ message = state.message,
+ // Retrying reloads the group and the candidates, which is what
+ // failed.
+ onRetry = { viewModel.initiate() }
+ )
+
+ SelectAdoptingGroupUIState.Loading -> LoadingDataIndicator()
+
+ is SelectAdoptingGroupUIState.Loaded -> Scaffold(
+ snackbarHost = { SnackbarHost(snackbarHostState) },
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(Res.string.who_adopts_this_group)) },
+ navigationIcon = { NavigateBackButton(onNavigateBack) },
+ )
+ }
+ ) { innerPadding ->
+ LazyColumn(
+ modifier = Modifier
+ .padding(innerPadding)
+ .readableContent()
+ .fillMaxSize()
+ .padding(MaterialTheme.spacing.containerPadding),
+ verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap)
+ ) {
+ if (state.candidates.isEmpty()) {
+ item {
+ // The message carries the reason, because the
+ // reason is the whole content of this absence: a
+ // group cannot be offered as a parent by somebody
+ // who is not in it, so the remedy is a person.
+ EmptyState(
+ message = stringResource(
+ Res.string.no_group_here_can_adopt_this_one
+ )
+ )
+ }
+ } else {
+ item {
+ Text(
+ text = stringResource(
+ Res.string.which_group_should_adopt_this_one
+ ),
+ style = MaterialTheme.typography.labelMedium
+ )
+ }
+
+ items(
+ items = state.candidates,
+ key = { candidate -> candidate.chatRoom.id }
+ ) { candidate ->
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ onClick = {
+ scope.launch {
+ viewModel.offerTo(candidate.chatRoom.id) { refusal ->
+ scope.launch {
+ snackbarHostState.showSnackbar(refusal ?: asked)
+ }
+ if (refusal == null) onNavigateBack()
+ }
+ }
+ }
+ ) {
+ ListItem(
+ leadingContent = {
+ Icon(
+ Icons.Default.AccountTree,
+ contentDescription = Decorative
+ )
+ },
+ trailingContent = {
+ Icon(
+ Icons.Default.ChevronRight,
+ contentDescription = Decorative
+ )
+ },
+ headlineContent = {
+ Text(
+ text = candidate.chatRoom.subject
+ ?: candidate.chatRoom.id.take(16),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ },
+ supportingContent = {
+ Text(
+ text = candidate.chatRoom.id.take(16),
+ fontFamily = FontFamily.Monospace,
+ maxLines = 1,
+ overflow = TextOverflow.MiddleEllipsis
+ )
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt
index 64ebcde5..f282dafd 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt
@@ -38,6 +38,7 @@ import press.mantra.compose.ui.composable.ChatRoomDetailScreen
import press.mantra.compose.ui.composable.ChatRoomMessagingScreen
import press.mantra.compose.ui.composable.CreateProfileScreen
import press.mantra.compose.ui.composable.DkgJoinApprovalScreen
+import press.mantra.compose.ui.composable.DkgRitualListScreen
import press.mantra.compose.ui.composable.DkgRitualScreen
import press.mantra.compose.ui.composable.DkgRound1ApprovalScreen
import press.mantra.compose.ui.composable.DkgRound2ApprovalScreen
@@ -99,7 +100,9 @@ import press.mantra.compose.ui.composable.navigation.routes.SearchMemberToAddToC
import press.mantra.compose.ui.composable.navigation.routes.SearchResultRoute
import press.mantra.compose.ui.composable.navigation.routes.SearchRoute
import press.mantra.compose.ui.composable.navigation.routes.SelectChatRoomMembersRoute
+import press.mantra.compose.ui.composable.navigation.routes.SelectAdoptingGroupRoute
import press.mantra.compose.ui.composable.navigation.routes.SelectSubgroupAdminsRoute
+import press.mantra.compose.ui.composable.SelectAdoptingGroupScreen
import press.mantra.compose.ui.composable.SelectSubgroupAdminsScreen
import press.mantra.compose.ui.composable.navigation.routes.SelectChatRoomTypeRoute
import press.mantra.compose.ui.composable.navigation.routes.ShareProfileRoute
@@ -140,6 +143,7 @@ import press.mantra.compose.ui.composable.navigation.routes.AddToProjectRoute
import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute
import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute
import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute
+import press.mantra.compose.ui.composable.navigation.routes.DkgRitualListRoute
import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute
import press.mantra.compose.ui.composable.navigation.routes.AddChapterRoute
import press.mantra.compose.ui.composable.navigation.routes.AddTranslationRoute
@@ -605,6 +609,18 @@ fun MantraNavHost(
}
)
}
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+
+ SelectAdoptingGroupScreen(
+ activeUserPublicKey = route.activeUserPublicKey,
+ chatRoomId = route.chatRoomId,
+ chatRepository = databaseChatRepository,
+ onNavigateBack = {
+ navController.popBackStack()
+ }
+ )
+ }
composable { backStackEntry ->
val route = backStackEntry.toRoute()
@@ -1257,6 +1273,22 @@ fun MantraNavHost(
}
)
}
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+
+ DkgRitualListScreen(
+ activeUserPublicKey = route.activeUserPublicKey,
+ chatRoomId = route.chatRoomId,
+ chatRepository = databaseChatRepository,
+ dkgRepository = databaseDkgRepository,
+ onNavigateBack = {
+ navController.popBackStack()
+ },
+ onNavigateToRoute = { ceremonyRoute ->
+ navController.navigate(route = ceremonyRoute)
+ }
+ )
+ }
composable { backStackEntry ->
val route = backStackEntry.toRoute()
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/DkgRitualListRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/DkgRitualListRoute.kt
new file mode 100644
index 00000000..e23efb54
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/DkgRitualListRoute.kt
@@ -0,0 +1,23 @@
+package press.mantra.compose.ui.composable.navigation.routes
+
+import kotlinx.serialization.Serializable
+
+/**
+ * Every key ceremony a room holds.
+ *
+ * The room's list, not the reader's: a ceremony is run by the whole group, and
+ * which of them are waiting on this member is a status on each row rather than
+ * a different list -- the same shape as [ProposalListRoute], for the same
+ * reason.
+ *
+ * A room holds more than one as a matter of course since a subgroup's ChillDKG
+ * moved into its parent's room, and until this there was no screen that could
+ * name the second: [DkgRitualRoute] without a session id shows the newest, and
+ * the newest is routinely the group's own, long finished, with the subgroup's
+ * request buried underneath it.
+ */
+@Serializable
+data class DkgRitualListRoute(
+ val activeUserPublicKey: String,
+ val chatRoomId: String
+): Route()
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/SelectAdoptingGroupRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/SelectAdoptingGroupRoute.kt
new file mode 100644
index 00000000..5c018ebc
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/SelectAdoptingGroupRoute.kt
@@ -0,0 +1,22 @@
+package press.mantra.compose.ui.composable.navigation.routes
+
+import kotlinx.serialization.Serializable
+
+/**
+ * Which group to ask to adopt this one.
+ *
+ * The first of an adoption's two signings, and the only one that needs a screen
+ * of its own: the acceptance is a card on the child's own detail screen, because
+ * by then there is something to accept.
+ *
+ * Carries only the child's id. The candidates are not on the route and must not
+ * be: they are the rooms the **profile in the chair** can act in, which a route
+ * cannot know and which changes while the screen is open -- see
+ * `SubgroupManager.adoptingCandidatesFor`.
+ */
+@Serializable
+data class SelectAdoptingGroupRoute(
+ val activeUserPublicKey: String,
+ val chatRoomId: String,
+ val relayHint: String? = null
+): Route()
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..a69c8b8d 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
@@ -578,7 +578,8 @@ fun ChatTranscript(
@Composable
private fun CeremoniesAwaitingYouNotice(
ceremonies: List,
- onClick: (dkgSessionId: String) -> Unit,
+ /** Null where the notice stands for more than one, which no single screen can show. */
+ onClick: (dkgSessionId: String?) -> Unit,
) {
val single = ceremonies.singleOrNull()
@@ -602,7 +603,11 @@ private fun CeremoniesAwaitingYouNotice(
}
Card(
- onClick = { onClick(ceremonies.first().sessionId) },
+ // The one it names when it names one, and the list otherwise. A notice
+ // reading "3 key ceremonies need you" that opened the first of them --
+ // which is what this did -- answered a different question from the one
+ // it asked, and left the other two exactly as hard to find as before.
+ onClick = { onClick(single?.sessionId) },
modifier = Modifier.fillMaxWidth().padding(horizontal = MaterialTheme.spacing.space50),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
@@ -812,7 +817,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/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt
index 78fd1923..37fe4e9e 100755
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt
@@ -156,6 +156,25 @@ class ChatMessageListViewModel(
proposalsAwaitingYou.size > 1 &&
proposalsAwaitingYou.any { it.sessionId == sessionId }
+ /**
+ * Whether opening [sessionId] by itself would hide other ceremonies the room
+ * is waiting on this member for.
+ *
+ * [hidesOtherDecisions]'s counterpart, and the same argument: a ceremony line
+ * names one ceremony, and opening it is right while that is the only one
+ * asking. A room holds more than one as a matter of course since a subgroup's
+ * ChillDKG moved into its parent's room, and the second is not in the
+ * transcript beside the first -- so answering the one they tapped and leaving
+ * would look exactly like being done.
+ *
+ * Only for a ceremony that is itself waiting on them. A line about a ceremony
+ * the group finished is history, and a reader who taps one is asking to see
+ * that, not to be handed the queue.
+ */
+ fun hidesOtherCeremonies(sessionId: String): Boolean =
+ ceremoniesAwaitingYou.size > 1 &&
+ ceremoniesAwaitingYou.any { it.sessionId == sessionId }
+
fun startDirectMessage(participant: Participant) {
logger.d("Arming a direct message to ${participant.participantPublicKey}")
directMessageRecipient = participant
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt
index 260d5da1..16fc5589 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt
@@ -82,14 +82,55 @@ class ChatRoomDetailViewModel(
groupKeyState = chatRepository.groupKeyState(chatRoomId),
signedGroupKeyStateEvent = chatRepository.signedGroupKeyStateEvent(chatRoomId),
subgroups = chatRepository.subgroupsOf(chatRoomId),
- parentChatRoomId = chatRepository.parentOf(chatRoomId),
+ parentChatRoomIds = chatRepository.parentsOf(chatRoomId),
canAddSubgroup = localChatRoom.chatRoom.mlsGroupState != null &&
chatRepository.canSign(chatRoomId),
+ adoptionOffers = chatRepository.adoptionOffersFor(chatRoomId),
+ // A key state as well as a share: the certificate carries
+ // this group's threshold key, and the acceptance re-announces
+ // that very state, so a room that has agreed nothing has
+ // neither half to work with.
+ canAddParent = localChatRoom.chatRoom.mlsGroupState != null &&
+ chatRepository.groupKeyState(chatRoomId) != null &&
+ chatRepository.canSign(chatRoomId),
)
}
}
}
+ /**
+ * Asks this group's quorum to accept [parentChatRoomId] as a parent.
+ *
+ * The acceptance, and the half that decides: the certificate is an offer
+ * until a quorum of *this* group has re-announced its key state naming them.
+ * `SubgroupManager.proposeAcceptance` carries the whole resulting set, so a
+ * group that already has a parent keeps it.
+ *
+ * [onDone] is called with null on success and a message otherwise, so the
+ * caller can show it from a scope that outlives this screen.
+ */
+ fun acceptParent(parentChatRoomId: HexKey, onDone: (String?) -> Unit) {
+ viewModelScope.launch(Dispatchers.IO) {
+ val accepted = chatRepository.acceptParent(
+ chatRoomId = chatRoomId,
+ parentChatRoomId = parentChatRoomId,
+ userPublicKey = activeUserPublicKey
+ )
+
+ if (!accepted) {
+ logger.e("Failed to ask $chatRoomId to accept $parentChatRoomId")
+ onDone("Couldn't ask this group to accept that parent. Please try again.")
+ return@launch
+ }
+
+ // The offer leaves the list only when the state is signed and filed,
+ // which is a quorum away -- so this reloads rather than crossing it
+ // off, and the card stays until the group has actually agreed.
+ initiateChatRoomDetail()
+ onDone(null)
+ }
+ }
+
fun leaveGroup(
localChatRoom: LocalChatRoom,
onPopBackToRoute: (Route) -> Unit
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualListViewModel.kt
new file mode 100644
index 00000000..38677d1c
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualListViewModel.kt
@@ -0,0 +1,140 @@
+package press.mantra.compose.ui.view.model
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import androidx.lifecycle.viewmodel.initializer
+import androidx.lifecycle.viewmodel.viewModelFactory
+import co.touchlab.kermit.Logger
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.IO
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import press.mantra.compose.database.model.DkgSession
+import press.mantra.compose.database.model.intermdiate.LocalChatRoom
+import press.mantra.compose.database.model.types.DkgRitualStage
+import press.mantra.compose.extensions.memberName
+import press.mantra.compose.repository.ChatRepository
+import press.mantra.compose.repository.DkgRepository
+import press.mantra.compose.ui.view.state.DkgRitualListUIState
+
+/**
+ * Every key ceremony a room holds.
+ *
+ * A room routinely holds more than one: a parent's Marmot room hosts the
+ * ceremony of every subgroup the group ever makes, alongside whatever it holds
+ * of its own. Watching the whole list rather than the newest is the point --
+ * which of them is waiting on the reader is a fact about each one, and the
+ * newest is routinely the finished one.
+ *
+ * The counterpart to [ProposalListViewModel], and deliberately the same shape.
+ */
+class DkgRitualListViewModel(
+ val chatRoomId: String,
+ val activeUserPublicKey: HexKey,
+ initialDkgRitualListUIState: DkgRitualListUIState,
+ val chatRepository: ChatRepository,
+ val dkgRepository: DkgRepository,
+): ViewModel() {
+
+ private val _dkgRitualListUIState = MutableStateFlow(initialDkgRitualListUIState)
+
+ /**
+ * A flow rather than a `mutableStateOf`: this is written from
+ * `Dispatchers.IO`, and a `mutableStateOf` created in a view model's
+ * constructor silently drops a write that lands during the composition that
+ * created it. See [DkgRitualViewModel.dkgRitualUIState].
+ */
+ val dkgRitualListUIState: StateFlow =
+ _dkgRitualListUIState.asStateFlow()
+
+ private val logger = Logger.withTag(TAG)
+
+ /**
+ * Loads the room, then watches its ceremonies for as long as the screen
+ * lives.
+ *
+ * Every one of them moves on messages arriving from other members, so a
+ * screen that read the list once would go stale the moment somebody else
+ * took their step -- which is exactly when a member is likely to be looking
+ * at it.
+ */
+ fun initiate() {
+ viewModelScope.launch(Dispatchers.IO) {
+ val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
+ if (localChatRoom == null) {
+ _dkgRitualListUIState.value =
+ DkgRitualListUIState.Error("Couldn't find the group")
+ return@launch
+ }
+
+ dkgRepository.observeSessionsForChatRoom(chatRoomId).collect { sessions ->
+ logger.d("${sessions.size} key ceremonies in $chatRoomId")
+
+ _dkgRitualListUIState.value = DkgRitualListUIState.Loaded(
+ localChatRoom = localChatRoom,
+ ceremonies = sessions.map { asCeremony(it, localChatRoom) }
+ )
+ }
+ }
+ }
+
+ /**
+ * One ceremony as a row.
+ *
+ * What it wants is asked of the repository rather than worked out here, so
+ * this list, the transcript's standing notice and the ceremony screen cannot
+ * disagree about which ceremonies still have something in them -- the same
+ * rule the proposal list keeps.
+ *
+ * An abandoned ceremony is asked nothing. It can want nothing: there is no
+ * step left to approve and no key to build a room from, and a row claiming
+ * otherwise would send a member to a screen with nothing on it.
+ */
+ private suspend fun asCeremony(
+ session: DkgSession,
+ localChatRoom: LocalChatRoom
+ ): DkgRitualListUIState.Ceremony {
+ val live = session.takeIf { it.stage != DkgRitualStage.FAILED }
+
+ return DkgRitualListUIState.Ceremony(
+ session = session,
+ coordinatorName = session.coordinatorPublicKey.memberName(
+ members = localChatRoom.localParticipants,
+ activeUserPublicKey = activeUserPublicKey
+ ),
+ pendingApproval = live?.let { dkgRepository.pendingApproval(it) },
+ // Only of the member who opened it: creating the room is the
+ // coordinator's job, and a row telling everybody else to do it would
+ // be telling them to do something they cannot.
+ roomAwaitingCreation = live
+ ?.takeIf { it.isCoordinator() }
+ ?.let { dkgRepository.roomAwaitingCreation(it) }
+ )
+ }
+
+ companion object {
+ private const val TAG = "DkgRitualListViewModel"
+
+ fun factory(
+ chatRoomId: String,
+ activeUserPublicKey: HexKey,
+ initialDkgRitualListUIState: DkgRitualListUIState = DkgRitualListUIState.Loading,
+ chatRepository: ChatRepository,
+ dkgRepository: DkgRepository,
+ ): ViewModelProvider.Factory = viewModelFactory {
+ initializer {
+ DkgRitualListViewModel(
+ chatRoomId = chatRoomId,
+ activeUserPublicKey = activeUserPublicKey,
+ initialDkgRitualListUIState = initialDkgRitualListUIState,
+ chatRepository = chatRepository,
+ dkgRepository = dkgRepository
+ )
+ }
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt
index d369fd14..5f6858ac 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt
@@ -1,9 +1,7 @@
package press.mantra.compose.ui.view.model
import androidx.compose.runtime.MutableState
-import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
@@ -36,7 +34,10 @@ import press.mantra.compose.identity.Identity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
/**
@@ -60,8 +61,51 @@ class DkgRitualViewModel(
val dkgRepository: DkgRepository,
): ViewModel() {
- var dkgRitualUIState: DkgRitualUIState by mutableStateOf(initialDkgRitualUIState)
- private set
+ private val _dkgRitualUIState = MutableStateFlow(initialDkgRitualUIState)
+
+ /**
+ * What the screen draws -- a flow rather than a `mutableStateOf`, because
+ * seven flows write it.
+ *
+ * The ceremony, the room's other ceremonies, the ceremony's messages, the
+ * key state, whether the key state is signed, the certificate and the
+ * certificate's signing all fold into this one object, and every one of
+ * them emits on Room's own executor. Held
+ * as a `var ... by mutableStateOf`, each of those was a read, a `copy` and
+ * an assignment with a suspension point in the middle, so two emissions
+ * arriving together silently dropped one: whichever read first wrote last,
+ * over the top of the other's change.
+ *
+ * The change that went missing most visibly was [DkgRitualUIState.Loaded.session],
+ * copied back to null by a watcher that had read the state before the
+ * ceremony landed in it -- and a screen with no session is the screen that
+ * offers to start one. It stayed that way until some other table changed and
+ * the session flow emitted again, which for a ceremony waiting on an absent
+ * member is never.
+ *
+ * [MutableStateFlow.update] retries on a losing compare-and-set, so a change
+ * can only be folded into the state that is actually current. Every write
+ * below goes through it -- see [updateLoaded]. It is the same rule the
+ * wallet view models already keep, for the same reason.
+ */
+ val dkgRitualUIState: StateFlow = _dkgRitualUIState.asStateFlow()
+
+ /**
+ * Folds a change into the loaded state, atomically, and does nothing when
+ * the screen is not in it -- the `as? Loaded ?: return` that every one of
+ * these call sites used to write by hand.
+ *
+ * [block] is re-run when it loses the race, so it has to be a pure function
+ * of the state it is handed: read nothing else out of the view model inside
+ * it, and do the suspending work above the call.
+ */
+ private inline fun updateLoaded(
+ block: (DkgRitualUIState.Loaded) -> DkgRitualUIState.Loaded
+ ) {
+ _dkgRitualUIState.update { current ->
+ if (current is DkgRitualUIState.Loaded) block(current) else current
+ }
+ }
private val logger = Logger.withTag(TAG)
@@ -70,9 +114,6 @@ class DkgRitualViewModel(
/** Watches the current session's messages; replaced whenever the session changes. */
private var messageObserver: Job? = null
- /** Watches the room's signing sessions, which is where the key state is agreed. */
- private var keyStateObserver: Job? = null
-
/** Watches the parent certifying this subgroup; re-pointed as the purpose is learned. */
private var certificateObserver: Job? = null
@@ -101,9 +142,15 @@ class DkgRitualViewModel(
* Any member may: ChillDKG gives the coordinator no say in the outcome, and a
* NIP-17 room records no creator to reserve the job for. What rules it out is
* a ceremony already running, or a group too small to split a key across.
+ *
+ * [state] is passed in by the screen rather than read off the view model,
+ * because the state is a flow now and reading `.value` inside composition
+ * observes nothing -- the offer would be decided once and never revised. The
+ * screen hands in the state it is drawing, which is also the only state the
+ * answer should be about while a transition still holds the outgoing one.
*/
- fun canStartRitual(): Boolean {
- val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return false
+ fun canStartRitual(state: DkgRitualUIState = dkgRitualUIState.value): Boolean {
+ val loaded = state as? DkgRitualUIState.Loaded ?: return false
if (loaded.session != null && loaded.session.stage != DkgRitualStage.FAILED) return false
@@ -116,8 +163,8 @@ class DkgRitualViewModel(
return ChillDkgRitualManager.canRunRitual(loaded.localChatRoom)
}
- fun quorumRange(): IntRange {
- val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded
+ fun quorumRange(state: DkgRitualUIState = dkgRitualUIState.value): IntRange {
+ val loaded = state as? DkgRitualUIState.Loaded
?: return ChatRoomType.quorumRange(ChatRoomType.MINIMUM_QUORUM)
// Below the minimum the range runs backwards and is empty, which `coerceIn`
@@ -137,17 +184,10 @@ class DkgRitualViewModel(
viewModelScope.launch(Dispatchers.IO) {
val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
if (localChatRoom == null) {
- dkgRitualUIState = DkgRitualUIState.Error("Couldn't load this chat")
+ _dkgRitualUIState.value = DkgRitualUIState.Error("Couldn't load this chat")
return@launch
}
- dkgRitualUIState = DkgRitualUIState.Loaded(localChatRoom = localChatRoom)
- threshold.value = ChatRoomType
- .defaultQuorum(ChillDkgRitualManager.memberPublicKeys(localChatRoom).size)
- .coerceIn(quorumRange())
-
- observeKeyState()
-
// Room-scoped, deliberately. Only the member who opened a subgroup
// arrives here knowing it is one -- everybody else reaches this screen
// from the room, through the transcript's ritual notice or the group's
@@ -171,30 +211,63 @@ class DkgRitualViewModel(
// Whether the room has any ceremony at all, which is a different
// question from which one is being shown -- see
// `DkgRitualUIState.roomHasCeremony`.
- launch {
- dkgRepository.observeLatestSessionForChatRoom(chatRoomId).collect { newest ->
- val current = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return@collect
+ val roomSessions = dkgRepository.observeLatestSessionForChatRoom(chatRoomId)
- dkgRitualUIState = current.copy(
- roomHasCeremony = newest != null && newest.stage != DkgRitualStage.FAILED
- )
+ // Both answers are read here, before anything is published, rather
+ // than awaited after a bare `Loaded` has gone up. A `Loaded` with no
+ // session and no ceremony *is* the screen that offers to start one,
+ // so publishing it first flashed "start key ceremony" over a ceremony
+ // already running -- for as long as the two queries took, which is
+ // long enough to read and long enough to tap. The flows below keep
+ // both current from here on; this only settles what the first frame
+ // says.
+ val shownSession = sessions.first()
+ val newestSession = roomSessions.first()
+
+ _dkgRitualUIState.value = DkgRitualUIState.Loaded(
+ localChatRoom = localChatRoom,
+ session = shownSession,
+ parentChatRoomId = shownSession?.parentChatRoomId,
+ pendingApproval = shownSession?.let { dkgRepository.pendingApproval(it) },
+ roomHasCeremony = newestSession != null &&
+ newestSession.stage != DkgRitualStage.FAILED
+ )
+
+ threshold.value = ChatRoomType
+ .defaultQuorum(ChillDkgRitualManager.memberPublicKeys(localChatRoom).size)
+ .coerceIn(quorumRange())
+
+ launch {
+ roomSessions.collect { newest ->
+ updateLoaded { current ->
+ current.copy(
+ roomHasCeremony = newest != null &&
+ newest.stage != DkgRitualStage.FAILED
+ )
+ }
}
}
sessions.collect { session ->
- val loaded = (dkgRitualUIState as? DkgRitualUIState.Loaded)
- ?: DkgRitualUIState.Loaded(localChatRoom = localChatRoom)
-
// The session is the only thing that knows what the ceremony is
// for. The route used to carry it too, which was right for the one
// member who opened the subgroup and null for everybody else.
val parent = session?.parentChatRoomId
- dkgRitualUIState = loaded.copy(
- session = session,
- parentChatRoomId = parent,
- pendingApproval = session?.let { dkgRepository.pendingApproval(it) }
- )
+ // Above the update, not inside it: `update` re-runs its block when
+ // it loses the race, and a block that re-runs cannot suspend.
+ val pending = session?.let { dkgRepository.pendingApproval(it) }
+
+ _dkgRitualUIState.update { current ->
+ val loaded = current as? DkgRitualUIState.Loaded
+ ?: DkgRitualUIState.Loaded(localChatRoom = localChatRoom)
+
+ loaded.copy(
+ session = session,
+ parentChatRoomId = parent,
+ pendingApproval = pending
+ )
+ }
// Re-point the per-ceremony watchers whenever the ceremony or its
// purpose changes, which for a member arriving cold is once.
@@ -212,23 +285,32 @@ class DkgRitualViewModel(
if (session != null) {
messageObserver = viewModelScope.launch(Dispatchers.IO) {
dkgRepository.observeMessages(session.id).collect { messages ->
- val current = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return@collect
-
fun senders(kind: Kind) = messages
.filter { it.kind == kind }
.map { it.participantPublicKey }
.toSet()
- dkgRitualUIState = current.copy(
- hostKeyParticipants = senders(DkgRitualEvents.HOST_KEY),
- round1Participants = senders(DkgRitualEvents.ROUND_1),
- round2Participants = senders(DkgRitualEvents.ROUND_2),
- // Recomputed here as well as on the session, because
- // whether round 1 can be asked for depends on how many
- // host keys have arrived, which only this flow sees.
- pendingApproval = current.session
- ?.let { dkgRepository.pendingApproval(it) }
- )
+ // Recomputed here as well as on the session, because
+ // whether round 1 can be asked for depends on how many
+ // host keys have arrived, which only this flow sees.
+ val pending = dkgRepository.pendingApproval(session)
+
+ updateLoaded { current ->
+ // This watcher belongs to one ceremony, and
+ // cancelling it does not reach into a body already
+ // running -- so say which ceremony these counts are
+ // for rather than letting them land on the next.
+ if (current.session?.id != session.id) {
+ return@updateLoaded current
+ }
+
+ current.copy(
+ hostKeyParticipants = senders(DkgRitualEvents.HOST_KEY),
+ round1Participants = senders(DkgRitualEvents.ROUND_1),
+ round2Participants = senders(DkgRitualEvents.ROUND_2),
+ pendingApproval = pending
+ )
+ }
}
}
}
@@ -236,45 +318,6 @@ class DkgRitualViewModel(
}
}
- /**
- * Follows the group agreeing what its admin room will sign with.
- *
- * Nothing here watches a [GroupKeyState] row, because there is no room yet
- * for one to belong to -- being able to follow this before the room exists
- * is the point. What is watched instead is the session, for how the signing
- * is going, and the signed event, for whether it finished.
- *
- * The room a ceremony runs in signs nothing else, so the newest session in
- * it is the key state's -- but it is matched on the kind it carries anyway,
- * so a room that one day signs something else does not confuse the two.
- */
- private fun observeKeyState() {
- keyStateObserver?.cancel()
- keyStateObserver = viewModelScope.launch(Dispatchers.IO) {
- // Two flows because they answer two questions off two tables. How
- // the signing is going is the session row; whether it finished is
- // the signed event, and only that second one can be asked at all
- // before there is a room -- which is the whole point of asking it.
- launch {
- dkgRepository.observeSigningSessions(chatRoomId).collect { sessions ->
- val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return@collect
-
- dkgRitualUIState = loaded.copy(
- keyStateSession = sessions.firstOrNull { local ->
- local.items.any { item ->
- runCatching {
- Event.fromJson(item.unsignedEventJson).kind
- }.getOrNull() == GroupKeyStateEvent.KIND
- }
- }?.session
- )
- }
- }
-
-
- }
- }
-
/**
* Follows one ceremony: whether its key state has been signed, and -- for a
* subgroup -- whether its parent has certified it.
@@ -293,54 +336,113 @@ class DkgRitualViewModel(
certificateObserver?.cancel()
if (dkgSessionId == null) {
- dkgRitualUIState = (dkgRitualUIState as? DkgRitualUIState.Loaded)
- ?.copy(isKeyStateSigned = false, isCertified = false, certificateSession = null)
- ?: dkgRitualUIState
+ updateLoaded {
+ it.copy(
+ isKeyStateSigned = false,
+ keyStateSession = null,
+ isCertified = false,
+ certificateSession = null
+ )
+ }
return
}
certificateObserver = viewModelScope.launch(Dispatchers.IO) {
+ // How the agreement is going, as opposed to whether it finished --
+ // two questions off two tables, and only the second can be asked
+ // before there is a room at all, which is the point of asking it.
+ //
+ // By the ceremony the proposal names, not by kind in this room. The
+ // room a ceremony runs in used to sign nothing else, and that is
+ // what this read assumed; a parent's Marmot room now hosts the
+ // ceremony of every subgroup the group makes, so it holds one of
+ // these per subgroup. Matching on kind alone handed the screen the
+ // newest of them -- which is some other subgroup's -- and with it a
+ // progress bar for a signing this ceremony is not in and a button
+ // disabled on the strength of it. Permanently, once that other one
+ // completed: the guard only reopens on a failure.
+ //
+ // `FrostKeyTag` is on the proposal from the moment it is made, so
+ // this works before the ceremony has a key and before anybody signs.
+ launch {
+ dkgRepository.observeSigningSessions(chatRoomId).collect { sessions ->
+ val keyState = sessions.firstOrNull { local ->
+ local.items.any { item ->
+ val event = runCatching {
+ Event.fromJson(item.unsignedEventJson)
+ }.getOrNull() ?: return@any false
+
+ event.kind == GroupKeyStateEvent.KIND &&
+ GroupKeyStateEvent.parseDkgSessionId(event.tags) == dkgSessionId
+ }
+ }?.session
+
+ updateLoaded { it.copy(keyStateSession = keyState) }
+ }
+ }
+
// Whether the group has agreed what the room this ceremony's key
// derives will sign with. Asked by ceremony rather than by room: the
// room holds however many ceremonies it holds, and the answer is
// about this one's key.
launch {
dkgRepository.observeSignedGroupKeyState(dkgSessionId).collect { state ->
- val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return@collect
-
- dkgRitualUIState = loaded.copy(isKeyStateSigned = state != null)
+ updateLoaded { it.copy(isKeyStateSigned = state != null) }
}
}
if (parentChatRoomId == null) {
- dkgRitualUIState = (dkgRitualUIState as? DkgRitualUIState.Loaded)
- ?.copy(isCertified = false, certificateSession = null)
- ?: dkgRitualUIState
+ updateLoaded { it.copy(isCertified = false, certificateSession = null) }
return@launch
}
launch {
dkgRepository.observeBirthCertificate(dkgSessionId, parentChatRoomId)
.collect { certificate ->
- val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return@collect
-
- dkgRitualUIState = loaded.copy(isCertified = certificate != null)
+ updateLoaded { it.copy(isCertified = certificate != null) }
}
}
launch {
+ // The child this ceremony is making, which is what a certificate
+ // names. Resolved per emission rather than once, because the
+ // ceremony has no key until it finishes and this flow is running
+ // before it does -- the same reason `observeBirthCertificate`
+ // resolves it per emission.
+ //
+ // Scoped to that child, not to the kind. A parent certifies every
+ // subgroup it ever makes from the same room, so by kind alone this
+ // found the newest certificate signing in the parent -- some other
+ // child's -- and read its progress as this one's. Worse, the rung
+ // disables "ask the group to certify" while a certificate session
+ // is running and un-failed, so once a parent had certified one
+ // subgroup it could never be asked to certify another.
dkgRepository.observeSigningSessions(parentChatRoomId).collect { sessions ->
- val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return@collect
+ val subgroupChatRoomId = dkgRepository.roomDerivedBy(dkgSessionId)
- dkgRitualUIState = loaded.copy(
- certificateSession = sessions.firstOrNull { local ->
+ val certificate = subgroupChatRoomId?.let { child ->
+ sessions.firstOrNull { local ->
local.items.any { item ->
- runCatching {
- Event.fromJson(item.unsignedEventJson).kind
- }.getOrNull() == SubgroupBirthCertificateEvent.KIND
+ val event = runCatching {
+ Event.fromJson(item.unsignedEventJson)
+ }.getOrNull() ?: return@any false
+
+ // `addresses`, not `certifies`: the thing being
+ // matched is a proposal, and `certifies` ends on
+ // a signature by the parent's quorum, which is
+ // precisely what has not happened yet. Whether
+ // the parent actually agreed is `isCertified`,
+ // which does check it.
+ SubgroupBirthCertificateEvent.addresses(
+ event = event,
+ subgroupChatRoomId = child,
+ parentChatRoomId = parentChatRoomId,
+ )
}
}?.session
- )
+ }
+
+ updateLoaded { it.copy(certificateSession = certificate) }
}
}
}
@@ -359,7 +461,7 @@ class DkgRitualViewModel(
fun proposeKeyState() {
if (isActionPending.value) return
- val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return
+ val loaded = dkgRitualUIState.value as? DkgRitualUIState.Loaded ?: return
val session = loaded.session ?: return
if (session.thresholdPublicKey == null) return
@@ -398,7 +500,7 @@ class DkgRitualViewModel(
if (signing == null) {
logger.e("Failed to propose the key state for $chatRoomId")
- dkgRitualUIState = DkgRitualUIState.Error(
+ _dkgRitualUIState.value = DkgRitualUIState.Error(
"Couldn't ask the group to agree its signing key. Please try again."
)
}
@@ -421,7 +523,7 @@ class DkgRitualViewModel(
fun proposeBirthCertificate() {
if (isActionPending.value) return
- val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return
+ val loaded = dkgRitualUIState.value as? DkgRitualUIState.Loaded ?: return
// The state's, not the route's. Every step after the ceremony is open to
// any of the subgroup's admins -- see `SubgroupManager` -- and they reach
// this screen from the room, with no route to have told them anything.
@@ -459,7 +561,7 @@ class DkgRitualViewModel(
if (signing == null) {
logger.e("Failed to propose a birth certificate in $parent")
- dkgRitualUIState = DkgRitualUIState.Error(
+ _dkgRitualUIState.value = DkgRitualUIState.Error(
"Couldn't ask the group to certify this subgroup. Please try again."
)
}
@@ -470,11 +572,11 @@ class DkgRitualViewModel(
fun startRitual() {
if (isActionPending.value) return
- val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return
+ val loaded = dkgRitualUIState.value as? DkgRitualUIState.Loaded ?: return
val nostrPrivateKey = activeIdentityStateFlow.value?.nostrPrivateKey
if (nostrPrivateKey == null) {
- dkgRitualUIState = DkgRitualUIState.Error("Couldn't read your keys. Please try again.")
+ _dkgRitualUIState.value = DkgRitualUIState.Error("Couldn't read your keys. Please try again.")
return
}
@@ -492,7 +594,7 @@ class DkgRitualViewModel(
if (session == null) {
logger.e("Failed to start DKG ritual for $chatRoomId")
- dkgRitualUIState = DkgRitualUIState.Error("Couldn't start the key ceremony. Please try again.")
+ _dkgRitualUIState.value = DkgRitualUIState.Error("Couldn't start the key ceremony. Please try again.")
}
// On success the session flow above delivers the new row; no need to
// set it here and risk racing the observer.
@@ -509,12 +611,12 @@ class DkgRitualViewModel(
fun approve(step: DkgApprovalStep) {
if (isActionPending.value) return
- val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return
+ val loaded = dkgRitualUIState.value as? DkgRitualUIState.Loaded ?: return
val session = loaded.session ?: return
val nostrPrivateKey = activeIdentityStateFlow.value?.nostrPrivateKey
if (nostrPrivateKey == null) {
- dkgRitualUIState = DkgRitualUIState.Error("Couldn't read your keys. Please try again.")
+ _dkgRitualUIState.value = DkgRitualUIState.Error("Couldn't read your keys. Please try again.")
return
}
@@ -558,7 +660,7 @@ class DkgRitualViewModel(
fun createAdminGroup(onNavigateToRoute: (Route) -> Unit) {
if (isActionPending.value) return
- val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return
+ val loaded = dkgRitualUIState.value as? DkgRitualUIState.Loaded ?: return
val session = loaded.session ?: return
val thresholdPublicKey = session.thresholdPublicKey ?: return
@@ -568,7 +670,7 @@ class DkgRitualViewModel(
val nostrPrivateKey = activeIdentityStateFlow.value?.nostrPrivateKey
if (nostrPrivateKey == null) {
- dkgRitualUIState = DkgRitualUIState.Error("Couldn't read your keys. Please try again.")
+ _dkgRitualUIState.value = DkgRitualUIState.Error("Couldn't read your keys. Please try again.")
return
}
@@ -594,7 +696,7 @@ class DkgRitualViewModel(
?: if (subgroupParent != null) "Subgroup" else "Group"
isActionPending.value = true
- dkgRitualUIState = loaded.copy(adminGroupBlockedOn = emptyList())
+ updateLoaded { it.copy(adminGroupBlockedOn = emptyList()) }
viewModelScope.launch(Dispatchers.IO) {
val outcome = chatRepository.createMarmotGroup(
@@ -607,11 +709,7 @@ class DkgRitualViewModel(
},
adminPublicKeys = members,
userPublicKey = activeUserPublicKey,
- keyPair = keyPair,
- // Verified by the time it is written: the key state this room was
- // gated on carries the certificate, and `stateFrom` refused it
- // otherwise.
- parentChatRoomId = subgroupParent
+ keyPair = keyPair
)
isActionPending.value = false
@@ -621,19 +719,21 @@ class DkgRitualViewModel(
// Named rather than counted. A member cannot publish a key
// package on somebody else's behalf, so the only useful thing
// to say is whose door to knock on.
- dkgRitualUIState = loaded.copy(
- adminGroupBlockedOn = outcome.missing.map { publicKey ->
- loaded.ritualMembers
- .firstOrNull { it.participant.participantPublicKey == publicKey }
- ?.profile
- ?.humanReadableNameOrPubkey()
- ?: publicKey.take(12)
- }
- )
+ updateLoaded { current ->
+ current.copy(
+ adminGroupBlockedOn = outcome.missing.map { publicKey ->
+ current.ritualMembers
+ .firstOrNull { it.participant.participantPublicKey == publicKey }
+ ?.profile
+ ?.humanReadableNameOrPubkey()
+ ?: publicKey.take(12)
+ }
+ )
+ }
}
is MarmotGroupCreation.Outcome.Failed -> {
- dkgRitualUIState = DkgRitualUIState.Error(outcome.reason)
+ _dkgRitualUIState.value = DkgRitualUIState.Error(outcome.reason)
}
is MarmotGroupCreation.Outcome.Created -> withContext(Main) {
@@ -661,7 +761,6 @@ class DkgRitualViewModel(
override fun onCleared() {
messageObserver?.cancel()
- keyStateObserver?.cancel()
certificateObserver?.cancel()
super.onCleared()
}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectAdoptingGroupViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectAdoptingGroupViewModel.kt
new file mode 100644
index 00000000..a46ef417
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectAdoptingGroupViewModel.kt
@@ -0,0 +1,138 @@
+package press.mantra.compose.ui.view.model
+
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import androidx.lifecycle.viewmodel.initializer
+import androidx.lifecycle.viewmodel.viewModelFactory
+import co.touchlab.kermit.Logger
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.IO
+import kotlinx.coroutines.launch
+import press.mantra.compose.repository.ChatRepository
+import press.mantra.compose.ui.view.state.SelectAdoptingGroupUIState
+
+/**
+ * Which group to ask to adopt this one.
+ *
+ * The first of an adoption's two signings. Confirming proposes a birth
+ * certificate in the **chosen** group's room, and that is all it does: the
+ * certificate is an **offer**, and this room is not that group's subgroup until
+ * its own quorum re-announces its key state naming them. So this screen hands
+ * back to the room it came from rather than anywhere new -- the acceptance is a
+ * card there, and the answer belongs to whoever opens it.
+ *
+ * ### The candidates cannot be anything but this profile's own rooms
+ *
+ * A certificate is proposed *inside* the adopting group, so a candidate has to be
+ * a room the key in the chair can act in: a Marmot room this profile administers
+ * and holds a share of, which is not this room, is not already a parent, has not
+ * already offered, and is not already beneath this one. Every one of those is
+ * `SubgroupManager.refuseAdoption`'s, run through that function rather than
+ * through a copy of its rules, so the screen and the manager cannot drift.
+ *
+ * An empty list is therefore a fact about this member rather than a failure, and
+ * the screen says so: a group cannot be offered as a parent by somebody who is
+ * not in it, and the remedy is a person rather than a button.
+ */
+class SelectAdoptingGroupViewModel(
+ val activeUserPublicKey: HexKey,
+ val chatRoomId: String,
+ initialUIState: SelectAdoptingGroupUIState,
+ val chatRepository: ChatRepository,
+): ViewModel() {
+
+ var uiState: SelectAdoptingGroupUIState by mutableStateOf(initialUIState)
+ private set
+
+ private val logger = Logger.withTag(TAG)
+
+ val isActionPending: MutableState = mutableStateOf(false)
+
+ fun initiate() {
+ viewModelScope.launch(Dispatchers.IO) {
+ val childRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
+ if (childRoom == null) {
+ uiState = SelectAdoptingGroupUIState.Error("Couldn't load this group")
+ return@launch
+ }
+
+ uiState = SelectAdoptingGroupUIState.Loaded(
+ childRoom = childRoom,
+ candidates = chatRepository.adoptingCandidatesFor(
+ chatRoomId = chatRoomId,
+ userPublicKey = activeUserPublicKey
+ )
+ )
+ }
+ }
+
+ /**
+ * Asks [adoptingChatRoomId]'s quorum to certify this room as its child, then
+ * hands back.
+ *
+ * Checked again here rather than trusted from the list, because the screen
+ * may have been open a while and a screen not drawing something is not a
+ * guard. The manager checks a third time; this one exists to say *why* rather
+ * than to throw.
+ */
+ fun offerTo(adoptingChatRoomId: String, onDone: (String?) -> Unit) {
+ if (isActionPending.value) return
+ isActionPending.value = true
+
+ viewModelScope.launch(Dispatchers.IO) {
+ val refusal = chatRepository.refuseAdoption(
+ chatRoomId = chatRoomId,
+ adoptingChatRoomId = adoptingChatRoomId,
+ userPublicKey = activeUserPublicKey
+ )
+
+ if (refusal != null) {
+ isActionPending.value = false
+ onDone(refusal)
+ return@launch
+ }
+
+ val proposed = chatRepository.proposeAdoption(
+ chatRoomId = chatRoomId,
+ adoptingChatRoomId = adoptingChatRoomId,
+ userPublicKey = activeUserPublicKey
+ )
+
+ isActionPending.value = false
+
+ if (!proposed) {
+ logger.e("Failed to ask $adoptingChatRoomId to adopt $chatRoomId")
+ onDone("Couldn't ask that group to adopt this one. Please try again.")
+ return@launch
+ }
+
+ onDone(null)
+ }
+ }
+
+ companion object {
+ private const val TAG = "SelectAdoptingGroupViewModel"
+
+ fun factory(
+ activeUserPublicKey: HexKey,
+ chatRoomId: String,
+ initialUIState: SelectAdoptingGroupUIState = SelectAdoptingGroupUIState.Loading,
+ chatRepository: ChatRepository,
+ ): ViewModelProvider.Factory = viewModelFactory {
+ initializer {
+ SelectAdoptingGroupViewModel(
+ activeUserPublicKey = activeUserPublicKey,
+ chatRoomId = chatRoomId,
+ initialUIState = initialUIState,
+ chatRepository = chatRepository
+ )
+ }
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt
index e8fbf0f3..c0647b39 100755
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt
@@ -53,7 +53,7 @@ sealed interface ChatRoomDetailUIState {
* founding holds no state and so sees nothing here, even in a room that
* really is a subgroup.
*/
- val parentChatRoomId: HexKey? = null,
+ val parentChatRoomIds: List = emptyList(),
/**
* Whether this device could open a subgroup here at all.
*
@@ -68,6 +68,29 @@ sealed interface ChatRoomDetailUIState {
* missing and what to do about it.
*/
val canAddSubgroup: Boolean = false,
+ /**
+ * Groups that have offered to adopt this one and are not parents yet.
+ *
+ * An offer is a certificate signed in the *other* group's room, so only a
+ * member of that group holds one -- which is the overlap an adoption
+ * needs, showing up here as a list that is empty for everybody else.
+ *
+ * Each is verified: the signature was checked against the group it names,
+ * which needs no lookup. What is unverified is nothing; what is
+ * *unsettled* is everything, because an offer is not a lineage until this
+ * group's own quorum says so.
+ */
+ val adoptionOffers: List = emptyList(),
+ /**
+ * Whether this device could put a parent to this group at all.
+ *
+ * The room has to be a Marmot one with a key state -- the certificate
+ * carries the child's threshold key and the acceptance re-announces that
+ * state -- and this device has to hold a share, because both halves are
+ * signatures by this group. The rest of the refusals are the picker's,
+ * where they can name which group and why.
+ */
+ val canAddParent: Boolean = false,
): ChatRoomDetailUIState
data class Error(
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DkgRitualListUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DkgRitualListUIState.kt
new file mode 100644
index 00000000..0e9e533e
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DkgRitualListUIState.kt
@@ -0,0 +1,68 @@
+package press.mantra.compose.ui.view.state
+
+import press.mantra.compose.database.model.DkgSession
+import press.mantra.compose.database.model.intermdiate.LocalChatRoom
+import press.mantra.compose.database.model.types.DkgApprovalStep
+
+sealed interface DkgRitualListUIState {
+ data class Loaded(
+ val localChatRoom: LocalChatRoom,
+
+ /** Every ceremony the room holds, newest first. */
+ val ceremonies: List = emptyList(),
+ ): DkgRitualListUIState {
+ /**
+ * The ones that cannot move until this member acts.
+ *
+ * Split out rather than sorted to the top, the way the proposal list
+ * splits its own -- the rest of the list is what the room has done,
+ * these are what it is waiting on the reader for. A room can have two
+ * at once the moment a subgroup's admins are the whole group, and
+ * burying the second under the first one's history is the whole reason
+ * this screen exists.
+ */
+ val waitingForYou: List = ceremonies.filter { it.awaitsYou }
+
+ val rest: List = ceremonies.filterNot { it.awaitsYou }
+ }
+
+ /**
+ * One ceremony, as a row.
+ *
+ * Both reasons a ceremony can want something are carried separately rather
+ * than folded into one flag, because they are owed by different people and
+ * read differently: an approval is this member's gate and nobody else can
+ * pass it, while finishing the setup is work the coordinator alone can come
+ * back to. The row says which.
+ */
+ data class Ceremony(
+ val session: DkgSession,
+
+ /** Whoever opened it, as a name to read. */
+ val coordinatorName: String,
+
+ /** What the ceremony is waiting on this member for, if anything. */
+ val pendingApproval: DkgApprovalStep? = null,
+
+ /**
+ * The room this finished ceremony's key derives, when it does not exist
+ * yet and this member is the one who would make it.
+ *
+ * Reaching COMPLETE finishes nothing on its own: the group has a key and
+ * somebody still has to get its state signed and create the room. The
+ * same reading the transcript's standing notice makes.
+ */
+ val roomAwaitingCreation: String? = null,
+ ) {
+ /** Whether this ceremony is making a subgroup rather than the room's own key. */
+ val isSubgroup: Boolean get() = session.parentChatRoomId != null
+
+ val awaitsYou: Boolean get() = pendingApproval != null || roomAwaitingCreation != null
+ }
+
+ data class Error(
+ val message: String
+ ): DkgRitualListUIState
+
+ data object Loading: DkgRitualListUIState
+}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SelectAdoptingGroupUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SelectAdoptingGroupUIState.kt
new file mode 100644
index 00000000..9255519b
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SelectAdoptingGroupUIState.kt
@@ -0,0 +1,24 @@
+package press.mantra.compose.ui.view.state
+
+import press.mantra.compose.database.model.intermdiate.LocalChatRoom
+
+sealed interface SelectAdoptingGroupUIState {
+ data class Loaded(
+ val childRoom: LocalChatRoom,
+ /**
+ * The groups this profile could ask, in room order.
+ *
+ * **The profile's own rooms, and nothing else is possible.** A
+ * certificate is proposed *inside* the adopting group, so a candidate has
+ * to be a room the key in the chair can already act in. Empty is a fact
+ * about this member rather than a failure -- a group cannot be offered as
+ * a parent by somebody who is not in it, and the remedy is a person
+ * rather than a button.
+ */
+ val candidates: List = emptyList(),
+ ): SelectAdoptingGroupUIState
+
+ data class Error(val message: String): SelectAdoptingGroupUIState
+
+ data object Loading: SelectAdoptingGroupUIState
+}
diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt
index baf9a15e..48892ee3 100644
--- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt
+++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt
@@ -25,7 +25,10 @@ import press.mantra.compose.database.model.GroupKeyState
import press.mantra.compose.extensions.toHex
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
+import press.mantra.compose.nostr.subgroup.tags.SubgroupBirthCertificateTag
+import press.mantra.compose.nostr.subgroup.tags.SubgroupParentTag
import press.mantra.compose.nostr.nip30303.ArtifactEvent
import press.mantra.compose.nostr.nip30303.ChapterEvent
import press.mantra.compose.nostr.nip30303.DialectEvent
@@ -72,6 +75,15 @@ class GroupKeyStateTest {
threshold = threshold
)
+ /** A third group, for the lineages that name two parents. */
+ private val secondParentKeyMaterial: KeyMaterial = Frost.trustedDealerKeygen(
+ thresholdSecretKey = PrivateKey(
+ ByteVector32("3c0c0a0000000000000000000000000000000000000000000000000000000000c3")
+ ),
+ nParticipants = participants,
+ threshold = threshold
+ )
+
private val thresholdPublicKey = keyMaterial.thresholdPublicKey.value.toHex()
/** The other group's key, for the states that name the wrong one. */
@@ -304,15 +316,15 @@ class GroupKeyStateTest {
fun `a state the group signed about the room it arrives in is taken`() {
val state = GroupKeyStateManager.stateFrom(chatRoomId, signedKeyState())
- assertEquals(chatRoomId, state?.chatRoomId)
- assertEquals("ceremony-1", state?.dkgSessionId)
- assertEquals(thresholdPublicKey, state?.thresholdPublicKey)
- assertEquals("m/9420/0/0", state?.derivationPath)
+ assertEquals(chatRoomId, state?.keyState?.chatRoomId)
+ assertEquals("ceremony-1", state?.keyState?.dkgSessionId)
+ assertEquals(thresholdPublicKey, state?.keyState?.thresholdPublicKey)
+ assertEquals("m/9420/0/0", state?.keyState?.derivationPath)
// Attribution and ordering come off the event, not off the clock. The
// author is the room itself now, which is the whole point: nobody in
// particular said this.
- assertEquals(chatRoomId, state?.announcedBy)
- assertEquals(Instant.fromEpochSeconds(1_700_000_000), state?.announcedAt)
+ assertEquals(chatRoomId, state?.keyState?.announcedBy)
+ assertEquals(Instant.fromEpochSeconds(1_700_000_000), state?.keyState?.announcedAt)
}
@Test
@@ -409,8 +421,8 @@ class GroupKeyStateTest {
)
)
- assertNotEquals(chatRoomId, state?.chatRoomId)
- assertEquals(siblingRoom, state?.chatRoomId)
+ assertNotEquals(chatRoomId, state?.keyState?.chatRoomId)
+ assertEquals(siblingRoom, state?.keyState?.chatRoomId)
}
@Test
@@ -472,7 +484,8 @@ class GroupKeyStateTest {
assertEquals(
chatRoomId,
- GroupKeyStateManager.stateFrom(chatRoomId, signedKeyState(tags = undirected))?.chatRoomId
+ GroupKeyStateManager.stateFrom(chatRoomId, signedKeyState(tags = undirected))
+ ?.keyState?.chatRoomId
)
}
@@ -757,6 +770,23 @@ class GroupKeyStateTest {
/** The parent group's room, which is `otherKeyMaterial` wearing a second hat. */
private val parentChatRoomId get() = identityOf(otherKeyMaterial).hex
+ /** A second, unrelated parent's room -- the group that adopts the child later. */
+ private val secondParentChatRoomId get() = identityOf(secondParentKeyMaterial).hex
+
+ /** A lineage of both parents, each with its own group's real signature. */
+ private fun bothParents() = SubgroupLineage.of(
+ listOf(
+ SubgroupParentage(parentChatRoomId, birthCertificate()),
+ SubgroupParentage(
+ secondParentChatRoomId,
+ birthCertificate(
+ parent = secondParentChatRoomId,
+ author = secondParentKeyMaterial
+ )
+ )
+ )
+ )
+
/** A certificate by [author]'s room saying [subgroupChatRoomId] is its child. */
private fun birthCertificate(
subgroupChatRoomId: String = chatRoomId,
@@ -786,7 +816,9 @@ class GroupKeyStateTest {
chatRoomId = chatRoomId,
dkgSessionId = "ceremony-1",
path = path,
- parent = SubgroupParentage(parentChatRoomId = parent, certificate = certificate)
+ lineage = SubgroupLineage.of(
+ SubgroupParentage(parentChatRoomId = parent, certificate = certificate)
+ )
)
)
@@ -794,18 +826,26 @@ class GroupKeyStateTest {
fun `a state whose parent certified it keeps the parent and the certificate`() {
val state = GroupKeyStateManager.stateFrom(chatRoomId, keyStateWithParent())
- assertEquals(parentChatRoomId, state?.parentChatRoomId)
- assertEquals(birthCertificate().toJson(), state?.birthCertificateJson)
+ assertEquals(listOf(parentChatRoomId), state?.parentChatRoomIds())
+ assertEquals(
+ birthCertificate().toJson(),
+ state?.parents?.single()?.birthCertificateJson
+ )
+ // And the row points back at the child, which is what makes it a row
+ // rather than a column.
+ assertEquals(chatRoomId, state?.parents?.single()?.chatRoomId)
}
@Test
- fun `a state claiming no parent keeps both fields null`() {
+ fun `a state claiming no parent carries no parent rows at all`() {
// Every group made before subgroups existed, and every top-level group
- // made after. Null here is the ordinary case, not missing data.
+ // made after. No rows is the ordinary case, not missing data -- and it is
+ // one way of saying "nobody's child" rather than two, which is why a
+ // lineage of none is null rather than empty.
val state = GroupKeyStateManager.stateFrom(chatRoomId, signedKeyState())
- assertNull(state?.parentChatRoomId)
- assertNull(state?.birthCertificateJson)
+ assertEquals(emptyList(), state?.parents)
+ assertNull(state?.lineage())
}
@Test
@@ -892,19 +932,183 @@ class GroupKeyStateTest {
chatRoomId = chatRoomId,
dkgSessionId = "ceremony-1",
path = path,
- parent = SubgroupParentage(parentChatRoomId, certificate)
+ lineage = SubgroupLineage.of(SubgroupParentage(parentChatRoomId, certificate))
)
assertTrue(GroupKeyStateEvent.claimsParentage(tags))
- assertEquals(parentChatRoomId, GroupKeyStateEvent.parseParentChatRoomId(tags))
- assertEquals(certificate.toJson(), GroupKeyStateEvent.parseBirthCertificate(tags)?.toJson())
+
+ val lineage = GroupKeyStateEvent.parseLineage(tags)
+
+ assertEquals(listOf(parentChatRoomId), lineage?.parentChatRoomIds)
+ assertEquals(certificate.toJson(), lineage?.parents?.single()?.certificate?.toJson())
// And the tags a top-level group is proposed on carry neither, so every
// state signed before subgroups existed reads exactly as it did.
val plain = GroupKeyStateEvent.assembleTags(chatRoomId, "ceremony-1", path)
assertFalse(GroupKeyStateEvent.claimsParentage(plain))
- assertNull(GroupKeyStateEvent.parseParentChatRoomId(plain))
- assertNull(GroupKeyStateEvent.parseBirthCertificate(plain))
+ assertNull(GroupKeyStateEvent.parseLineage(plain))
+ }
+
+ @Test
+ fun `two parents land as adjacent pairs, each id before its own certificate`() {
+ // The one thing in this design that exists for *other people's* builds. A
+ // reader that predates several parents takes the first `parent_group` and
+ // the first `birth_certificate` and checks them against each other, so
+ // the pairs have to be adjacent and in step. Were they emitted as all the
+ // ids and then all the certificates, such a reader would pair one
+ // parent's id with another's certificate, fail `certifies`, and drop the
+ // whole state -- losing the row that says which share signs for the room.
+ val tags = GroupKeyStateEvent.assembleTags(
+ chatRoomId = chatRoomId,
+ dkgSessionId = "ceremony-1",
+ path = path,
+ lineage = bothParents()
+ )
+
+ val names = tags.map { it[0] }.filter {
+ it == SubgroupParentTag.TAG_NAME || it == SubgroupBirthCertificateTag.TAG_NAME
+ }
+
+ assertEquals(
+ listOf(
+ SubgroupParentTag.TAG_NAME,
+ SubgroupBirthCertificateTag.TAG_NAME,
+ SubgroupParentTag.TAG_NAME,
+ SubgroupBirthCertificateTag.TAG_NAME
+ ),
+ names
+ )
+
+ // And the first pair an older reader would take is a matched one: the
+ // certificate beside the first id is the certificate *by* that parent.
+ val firstParent = tags.first { it[0] == SubgroupParentTag.TAG_NAME }[1]
+ val firstCertificate = tags.first { it[0] == SubgroupBirthCertificateTag.TAG_NAME }[1]
+
+ assertEquals(firstParent, Event.fromJson(firstCertificate).pubKey)
+ }
+
+ @Test
+ fun `a state claiming two parents keeps both, each with its own certificate`() {
+ // Two groups, two quorums, two signatures over the same child's id. The
+ // second one signed later and could not have named the first as a
+ // co-parent, which is why nothing requires the certificates to agree on
+ // the set -- the child's key state is the sole authority on it.
+ val state = signedKeyState(
+ tags = GroupKeyStateEvent.assembleTags(
+ chatRoomId = chatRoomId,
+ dkgSessionId = "ceremony-1",
+ path = path,
+ lineage = bothParents()
+ )
+ )
+
+ val local = GroupKeyStateManager.stateFrom(chatRoomId, state)
+
+ assertEquals(
+ listOf(parentChatRoomId, secondParentChatRoomId).sorted(),
+ local?.parentChatRoomIds()?.sorted()
+ )
+ assertEquals(2, local?.parents?.size)
+ // Each row carries the certificate of the parent it names, not whichever
+ // one came first off the tags.
+ local?.parents?.forEach { parent ->
+ assertEquals(
+ parent.parentChatRoomId,
+ Event.fromJson(parent.birthCertificateJson).pubKey,
+ "each parentage row keeps its own parent's certificate"
+ )
+ }
+ }
+
+ @Test
+ fun `one bad certificate in a set of two drops the whole state`() {
+ // All or nothing, generalised. Keeping the provable half would file a
+ // group as having one parent when its own members see two, and a set
+ // that is partly believed is not a weaker set.
+ val state = signedKeyState(
+ tags = GroupKeyStateEvent.assembleTags(
+ chatRoomId = chatRoomId,
+ dkgSessionId = "ceremony-1",
+ path = path,
+ lineage = SubgroupLineage.of(
+ listOf(
+ SubgroupParentage(parentChatRoomId, birthCertificate()),
+ // Really signed by the second parent, but for a different
+ // child -- so it certifies something, just not this.
+ SubgroupParentage(
+ secondParentChatRoomId,
+ birthCertificate(
+ subgroupChatRoomId = otherKey,
+ parent = secondParentChatRoomId,
+ author = secondParentKeyMaterial
+ )
+ )
+ )
+ )
+ )
+ )
+
+ assertNull(GroupKeyStateManager.stateFrom(chatRoomId, state))
+ }
+
+ @Test
+ fun `a state naming the same parent twice is dropped`() {
+ val certificate = birthCertificate()
+ val doubled = GroupKeyStateEvent.assembleTags(
+ chatRoomId = chatRoomId,
+ dkgSessionId = "ceremony-1",
+ path = path,
+ lineage = SubgroupLineage.of(SubgroupParentage(parentChatRoomId, certificate))
+ ) + arrayOf(
+ SubgroupParentTag.assemble(parentChatRoomId),
+ SubgroupBirthCertificateTag.assemble(certificate)
+ )
+
+ assertTrue(GroupKeyStateEvent.claimsParentage(doubled))
+ assertNull(GroupKeyStateEvent.parseLineage(doubled))
+ assertNull(GroupKeyStateManager.stateFrom(chatRoomId, signedKeyState(tags = doubled)))
+ }
+
+ @Test
+ fun `an uneven number of halves is refused, not completed from the other`() {
+ val certificate = birthCertificate()
+
+ val extraParent = GroupKeyStateEvent.assembleTags(
+ chatRoomId = chatRoomId,
+ dkgSessionId = "ceremony-1",
+ path = path,
+ lineage = SubgroupLineage.of(SubgroupParentage(parentChatRoomId, certificate))
+ ) + arrayOf(SubgroupParentTag.assemble(chatRoomId))
+
+ assertNull(GroupKeyStateEvent.parseLineage(extraParent))
+
+ val extraCertificate = GroupKeyStateEvent.assembleTags(
+ chatRoomId = chatRoomId,
+ dkgSessionId = "ceremony-1",
+ path = path,
+ lineage = SubgroupLineage.of(SubgroupParentage(parentChatRoomId, certificate))
+ ) + arrayOf(SubgroupBirthCertificateTag.assemble(certificate))
+
+ assertNull(GroupKeyStateEvent.parseLineage(extraCertificate))
+ }
+
+ @Test
+ fun `a half that is present and broken shifts nothing, because it is counted`() {
+ // Counted by tag name as well as by successful parse, so a broken half is
+ // told from one that was never written. Only the first would slide the
+ // pairing along by one and pair an id with somebody else's certificate.
+ val broken = GroupKeyStateEvent.assembleTags(
+ chatRoomId = chatRoomId,
+ dkgSessionId = "ceremony-1",
+ path = path,
+ lineage = SubgroupLineage.of(SubgroupParentage(parentChatRoomId, birthCertificate()))
+ ) + arrayOf(
+ SubgroupParentTag.assemble(chatRoomId),
+ arrayOf(SubgroupBirthCertificateTag.TAG_NAME, "not an event")
+ )
+
+ assertTrue(GroupKeyStateEvent.claimsParentage(broken))
+ assertNull(GroupKeyStateEvent.parseLineage(broken))
}
}
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..ada04fb2 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,237 @@ 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))
+ }
+
+ // ---- `addresses`: the half that can be asked of a proposal ------------
+ //
+ // The ceremony screen has to tell one *in-flight* certificate signing from
+ // another in the parent's room, and a parent certifies every subgroup it
+ // ever makes from that one room. `certifies` cannot answer it: it ends on a
+ // signature by the parent's quorum, which is exactly what the screen is
+ // waiting for. `addresses` is the structural half, and the only thing worth
+ // asserting about it is where it stops.
+
+ @Test
+ fun `an unsigned proposal addresses its child, and certifies nothing`() {
+ // What the ceremony screen is actually holding: the event as it was
+ // assembled, before anybody signed it. Any string in `sig` serves --
+ // the point is that no quorum has put one there.
+ val proposal = certificate(signature = "0".repeat(128))
+
+ assertTrue(
+ SubgroupBirthCertificateEvent.addresses(
+ event = proposal,
+ subgroupChatRoomId = subgroupChatRoomId,
+ parentChatRoomId = parentChatRoomId
+ ),
+ "the screen could not tell which subgroup its own proposal is about"
+ )
+
+ assertFalse(
+ SubgroupBirthCertificateEvent.certifies(
+ event = proposal,
+ subgroupChatRoomId = subgroupChatRoomId,
+ parentChatRoomId = parentChatRoomId
+ ),
+ "an unsigned proposal is being read as a parent's certificate"
+ )
+ }
+
+ @Test
+ fun `a forgery by another group addresses the pair it names, and certifies nothing`() {
+ // The reason `addresses` must never decide a lineage: anybody can author
+ // an event naming any pair, and this says yes to it. The guard is that
+ // nothing downstream of a lineage asks `addresses` -- only the screen
+ // deciding which of its own room's signings to draw does.
+ val forged = certificate(author = childMaterial)
+
+ assertTrue(
+ SubgroupBirthCertificateEvent.addresses(
+ event = forged,
+ subgroupChatRoomId = subgroupChatRoomId,
+ parentChatRoomId = parentChatRoomId
+ )
+ )
+
+ assertFalse(
+ SubgroupBirthCertificateEvent.certifies(
+ event = forged,
+ subgroupChatRoomId = subgroupChatRoomId,
+ parentChatRoomId = parentChatRoomId
+ )
+ )
+ }
+
+ @Test
+ fun `a sibling's proposal is not addressed to this child`() {
+ // Two subgroups of one parent, which is the case the screen exists to
+ // tell apart. The sibling's proposal is a real, well-formed certificate
+ // request; it is simply about somebody else.
+ val siblingRoomId = SharedKeyDerivation
+ .marmotGroupId(strangerMaterial.thresholdPublicKey.value.toHex(), path)
+
+ val sibling = certificate(
+ content = siblingRoomId,
+ tags = SubgroupBirthCertificateEvent.assembleTags(
+ subgroupChatRoomId = siblingRoomId,
+ parentChatRoomId = parentChatRoomId,
+ thresholdPublicKey = strangerMaterial.thresholdPublicKey.value.toHex(),
+ adminPublicKeys = admins,
+ name = "Another team",
+ path = path
+ )
+ )
+
+ assertFalse(
+ SubgroupBirthCertificateEvent.addresses(
+ event = sibling,
+ subgroupChatRoomId = subgroupChatRoomId,
+ parentChatRoomId = parentChatRoomId
+ ),
+ "one subgroup's rung is following a sibling's proposal"
+ )
+
+ // And it is addressed to the sibling, so the sibling's own screen finds
+ // it -- the two are told apart rather than both being refused.
+ assertTrue(
+ SubgroupBirthCertificateEvent.addresses(
+ event = sibling,
+ subgroupChatRoomId = siblingRoomId,
+ parentChatRoomId = parentChatRoomId
+ )
+ )
+ }
+
+ @Test
+ fun `a proposal naming another parent is not addressed by this one`() {
+ val fromElsewhere = certificate(
+ tags = SubgroupBirthCertificateEvent.assembleTags(
+ subgroupChatRoomId = subgroupChatRoomId,
+ parentChatRoomId = SharedKeyDerivation
+ .marmotGroupId(strangerMaterial.thresholdPublicKey.value.toHex(), path),
+ thresholdPublicKey = childKey,
+ adminPublicKeys = admins,
+ name = name,
+ path = path
+ )
+ )
+
+ assertFalse(
+ SubgroupBirthCertificateEvent.addresses(
+ event = fromElsewhere,
+ subgroupChatRoomId = subgroupChatRoomId,
+ parentChatRoomId = parentChatRoomId
+ )
+ )
+ }
+
+ @Test
+ fun `everything certifies addresses, so the weaker one cannot be the stricter`() {
+ // `certifies` calls `addresses` and adds to it, and this is what keeps
+ // that true: a change that let one pass where the other refuses would
+ // mean two different answers to "which child is this about".
+ val valid = certificate()
+
+ assertTrue(
+ SubgroupBirthCertificateEvent.certifies(
+ event = valid,
+ subgroupChatRoomId = subgroupChatRoomId,
+ parentChatRoomId = parentChatRoomId
+ )
+ )
+ assertTrue(
+ SubgroupBirthCertificateEvent.addresses(
+ event = valid,
+ subgroupChatRoomId = subgroupChatRoomId,
+ parentChatRoomId = parentChatRoomId
+ )
+ )
+ }
+
private fun certificate(
content: String = subgroupChatRoomId,
tags: Array> = SubgroupBirthCertificateEvent.assembleTags(
diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupLineageTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupLineageTest.kt
new file mode 100644
index 00000000..7c8c31df
--- /dev/null
+++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupLineageTest.kt
@@ -0,0 +1,161 @@
+package press.mantra.compose.nostr.subgroup
+
+import com.vitorpamplona.quartz.nip01Core.core.Event
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertSame
+import kotlin.test.assertTrue
+
+/**
+ * The two invariants a lineage holds, and the three edits it allows.
+ *
+ * Nothing here signs anything. `SubgroupLineage` is about the *shape* of a
+ * parentage set -- no repeats, one canonical order -- and the certificates in it
+ * are deliberately nonsense, because whether a certificate is good is
+ * `SubgroupBirthCertificateEvent.certifies`' question and is asked on every read
+ * rather than at the door. A test here that needed a real signature would be
+ * testing the wrong class.
+ */
+class SubgroupLineageTest {
+ private val alpha = "a".repeat(64)
+ private val beta = "b".repeat(64)
+ private val gamma = "c".repeat(64)
+
+ @Test
+ fun `no parents is no lineage rather than an empty one`() {
+ assertNull(SubgroupLineage.of(emptyList()))
+ }
+
+ @Test
+ fun `one parent cannot fail, because it has nothing to repeat`() {
+ val lineage = SubgroupLineage.of(parentage(alpha))
+
+ assertEquals(1, lineage.size)
+ assertEquals(listOf(alpha), lineage.parentChatRoomIds)
+ assertTrue(lineage.names(alpha))
+ assertFalse(lineage.names(beta))
+ }
+
+ @Test
+ fun `the same parent twice is refused rather than folded`() {
+ // One parent asked twice, carrying two certificates. Folding them would
+ // be this device picking which of the parent's statements counts.
+ assertNull(SubgroupLineage.of(listOf(parentage(alpha), parentage(alpha))))
+ }
+
+ @Test
+ fun `a repeat is a repeat whatever case it is written in`() {
+ val lineage = SubgroupLineage.of(listOf(parentage(alpha), parentage(alpha.uppercase())))
+
+ assertNull(lineage)
+ }
+
+ @Test
+ fun `a room id is matched case-insensitively, because it is hex`() {
+ val lineage = SubgroupLineage.of(parentage(alpha))
+
+ assertTrue(lineage.names(alpha.uppercase()))
+ }
+
+ @Test
+ fun `the order is the parents' ids, whatever order they arrived in`() {
+ // The whole reason this class sorts: two devices assembling one lineage
+ // from the same certificates have to emit the same bytes, or a reader
+ // that pairs the first tag of each name gets a mismatched pair.
+ val forwards = SubgroupLineage.of(listOf(parentage(alpha), parentage(beta), parentage(gamma)))
+ val backwards = SubgroupLineage.of(listOf(parentage(gamma), parentage(beta), parentage(alpha)))
+
+ assertEquals(listOf(alpha, beta, gamma), forwards?.parentChatRoomIds)
+ assertEquals(listOf(alpha, beta, gamma), backwards?.parentChatRoomIds)
+ }
+
+ @Test
+ fun `sorting is on the id and not on the case it is written in`() {
+ val lineage = SubgroupLineage.of(listOf(parentage(beta.uppercase()), parentage(alpha)))
+
+ // Sorted by the lowercased id, and the id itself is kept as it arrived --
+ // the certificate is the authority on what the parent is called, and this
+ // tag is only an index into it.
+ assertEquals(listOf(alpha, beta.uppercase()), lineage?.parentChatRoomIds)
+ }
+
+ @Test
+ fun `adding a parent is an addition, not a replacement`() {
+ // A key state is replaceable and the newest wins wholesale, so an
+ // adoption that carried only the new parent would silently drop the old
+ // one. Expressing it as an addition is what stops a caller assembling
+ // the resulting set by hand and forgetting half of it.
+ val adopted = SubgroupLineage.of(parentage(beta)).plus(parentage(alpha))
+
+ assertEquals(listOf(alpha, beta), adopted?.parentChatRoomIds)
+ }
+
+ @Test
+ fun `adding a parent that is already there is refused`() {
+ assertNull(SubgroupLineage.of(parentage(alpha)).plus(parentage(alpha)))
+ }
+
+ @Test
+ fun `dropping the last parent leaves no lineage at all`() {
+ // Null is "nobody's child", which is what a caller passes on. It revokes
+ // nothing: the certificate is a signature and exists forever, and what
+ // this removes is the child's half of the link.
+ assertNull(SubgroupLineage.of(parentage(alpha)).minus(alpha))
+ }
+
+ @Test
+ fun `dropping one of two leaves the other`() {
+ val lineage = SubgroupLineage.of(listOf(parentage(alpha), parentage(beta)))
+
+ assertEquals(listOf(beta), lineage?.minus(alpha)?.parentChatRoomIds)
+ assertEquals(listOf(alpha), lineage?.minus(beta)?.parentChatRoomIds)
+ }
+
+ @Test
+ fun `dropping a parent that was never there changes nothing`() {
+ val lineage = SubgroupLineage.of(listOf(parentage(alpha), parentage(beta)))
+
+ assertEquals(listOf(alpha, beta), lineage?.minus(gamma)?.parentChatRoomIds)
+ }
+
+ @Test
+ fun `the certificates ride along with their own parents`() {
+ // The pairing is the point of the type: an id and the certificate beside
+ // it have to stay together through the sort, or `certifies` would be
+ // asked about a pair the group never signed.
+ val lineage = SubgroupLineage.of(listOf(parentage(gamma), parentage(alpha)))
+ val parents = lineage?.parents.orEmpty()
+
+ assertEquals(alpha, parents[0].parentChatRoomId)
+ assertEquals(alpha, parents[0].certificate.content)
+ assertEquals(gamma, parents[1].parentChatRoomId)
+ assertEquals(gamma, parents[1].certificate.content)
+ }
+
+ @Test
+ fun `a parentage is carried as it was handed over`() {
+ val parentage = parentage(alpha)
+
+ assertSame(parentage, SubgroupLineage.of(parentage).parents.single())
+ }
+
+ /**
+ * A parentage whose certificate says only which parent it belongs to, so a
+ * test can tell two of them apart after a sort. Not a valid certificate and
+ * not meant to be one.
+ */
+ private fun parentage(parentChatRoomId: String) = SubgroupParentage(
+ parentChatRoomId = parentChatRoomId,
+ certificate = Event(
+ id = "d".repeat(64),
+ pubKey = parentChatRoomId,
+ createdAt = 1_700_000_000,
+ kind = SubgroupBirthCertificateEvent.KIND,
+ tags = emptyArray(),
+ content = parentChatRoomId,
+ sig = "e".repeat(128)
+ )
+ )
+}
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 5992bab2..ce727207 100644
--- a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt
+++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt
@@ -7,6 +7,10 @@ import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEven
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
+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.
@@ -151,6 +155,91 @@ class ProposedEventTest {
assertEquals(translationId.take(12), 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(),
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/SubgroupDaoJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/SubgroupDaoJvmTest.kt
index cb040477..1e1a9e9f 100644
--- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/SubgroupDaoJvmTest.kt
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/SubgroupDaoJvmTest.kt
@@ -16,31 +16,38 @@ import press.mantra.compose.database.builder.getRoomDatabase
import press.mantra.compose.database.model.ChatRoom
import press.mantra.compose.database.model.DkgSession
import press.mantra.compose.database.model.GroupKeyState
+import press.mantra.compose.database.model.GroupKeyStateParent
import press.mantra.compose.database.model.NostrEvent
import press.mantra.compose.database.model.Profile
import press.mantra.compose.database.model.types.DkgRitualStage
/**
- * What schema 17 has to survive, and the one thing it must refuse to do.
+ * What schema 21 has to survive, and the two things it must refuse to do.
*
- * Four nullable columns say a group is somebody's child. Three properties of
- * them are invisible to the compiler and each one is a way subgroups could break
- * quietly.
+ * A room's parentage is a table now rather than two nullable columns, because a
+ * room can be the child of more than one group -- founded with one and adopted by
+ * another later. Four properties of that are invisible to the compiler and each
+ * one is a way a lineage could break quietly.
*
* A parent pointer **may name a room this device does not have**. A member of a
* subgroup who was never in its parent holds the id and nothing else, so a
- * dangling value is the normal state rather than an error -- and if any of these
- * columns were ever given a foreign key, that member's rows would stop being
+ * dangling value is the normal state rather than an error -- and if the parent
+ * side were ever given a foreign key, that member's rows would stop being
* writable at all.
*
* Deleting a parent **must not touch its children**. Every other reference to a
- * room in this schema cascades. A self-referential cascade here would mean a user
+ * room in this schema cascades. A self-referential cascade would mean a user
* tidying away a group they had left silently destroying a group they are still
* in, together with its messages, participants, key state and signed work.
*
- * And the two `GroupKeyState` columns **travel together**. One without the other
- * is a state whose parentage cannot be rechecked from the row, which is the whole
- * reason the certificate is stored whole rather than as a signature.
+ * A state's lineage **is replaced wholesale, never merged**. A key state is
+ * replaceable and the newest wins, so the set of parents it names is the set.
+ * That is what makes a disavowal expressible at all: a room drops a parent by
+ * re-announcing without it, and merged rows would leave the dropped parent
+ * surviving the statement that dropped it.
+ *
+ * And a lineage **does not outlive the state that claimed it**, which is the one
+ * cascade here that is wanted.
*/
class SubgroupDaoJvmTest {
@@ -59,83 +66,72 @@ class SubgroupDaoJvmTest {
/** A parent this device has never held a room for. */
private val absentParent = "33".repeat(32)
- private val certificate =
- """{"id":"aa","pubkey":"bb","created_at":1700000000,"kind":30329,"tags":[],"content":"cc","sig":"dd"}"""
+ private fun certificate(by: String) =
+ """{"id":"aa","pubkey":"$by","created_at":1700000000,"kind":30329,""" +
+ """"tags":[],"content":"cc","sig":"dd"}"""
@Test
- fun `a key state keeps its parent and the certificate behind it`() = runBlocking {
+ fun `a key state keeps every parent and the certificate behind each`() = runBlocking {
seedRooms()
- db.groupKeyStateDao().upsert(
- keyState(
- chatRoomId = childRoom,
- parentChatRoomId = parentRoom,
- birthCertificateJson = certificate
- )
+ db.groupKeyStateDao().replace(
+ keyState(chatRoomId = childRoom),
+ listOf(parentage(parentRoom), parentage(absentParent))
)
- val stored = db.groupKeyStateDao().getByChatRoomId(childRoom)
+ val stored = db.groupKeyStateDao().getLocalByChatRoomId(childRoom)
- assertEquals(parentRoom, stored?.parentChatRoomId)
- assertEquals(certificate, stored?.birthCertificateJson)
+ assertEquals(
+ listOf(parentRoom, absentParent).sorted(),
+ stored?.parents?.map { it.parentChatRoomId }?.sorted()
+ )
+ assertEquals(
+ certificate(parentRoom),
+ stored?.parents?.single { it.parentChatRoomId == parentRoom }?.birthCertificateJson
+ )
}
@Test
- fun `a group that is nobody's child keeps both columns null`() = runBlocking {
+ fun `a group that is nobody's child carries no parent rows`() = runBlocking {
seedRooms()
- db.groupKeyStateDao().upsert(keyState(chatRoomId = parentRoom))
+ db.groupKeyStateDao().replace(keyState(chatRoomId = parentRoom))
- val stored = db.groupKeyStateDao().getByChatRoomId(parentRoom)
+ val stored = db.groupKeyStateDao().getLocalByChatRoomId(parentRoom)
// Every group made before subgroups existed reads back this way, and so
- // does every top-level group made after. Null here is not missing data.
- assertNull(stored?.parentChatRoomId)
- assertNull(stored?.birthCertificateJson)
+ // does every top-level group made after. No rows is not missing data, and
+ // it is one way of saying "nobody's child" rather than two.
+ assertEquals(emptyList(), stored?.parents)
+ assertNull(stored?.lineage())
}
@Test
- fun `a key state may name a parent this device holds no room for`() = runBlocking {
+ fun `a parentage may name a parent this device holds no room for`() = runBlocking {
seedRooms()
// The position a subgroup member who was never in the parent is in: they
- // hold the id off a certificate and nothing else. If this column were a
- // foreign key their state could not be written at all.
- db.groupKeyStateDao().upsert(
- keyState(
- chatRoomId = childRoom,
- parentChatRoomId = absentParent,
- birthCertificateJson = certificate
- )
+ // hold the id off a certificate and nothing else. Were the parent side a
+ // foreign key their lineage could not be written at all.
+ db.groupKeyStateDao().replace(
+ keyState(chatRoomId = childRoom),
+ listOf(parentage(absentParent))
)
- assertEquals(absentParent, db.groupKeyStateDao().getByChatRoomId(childRoom)?.parentChatRoomId)
- assertNull(db.chatRoomDao().findChatRoomById(absentParent))
- }
-
- @Test
- fun `a room may name a parent this device holds no room for`() = runBlocking {
- seedRooms()
-
- db.chatRoomDao().upsert(room(childRoom).copy(parentChatRoomId = absentParent))
-
assertEquals(
- absentParent,
- db.chatRoomDao().findChatRoomById(childRoom)?.chatRoom?.parentChatRoomId
+ listOf(absentParent),
+ db.groupKeyStateDao().getLocalByChatRoomId(childRoom)?.parents?.map { it.parentChatRoomId }
)
+ assertNull(db.chatRoomDao().findChatRoomById(absentParent))
}
@Test
fun `deleting a parent room leaves its subgroup standing`() = runBlocking {
seedRooms()
- db.chatRoomDao().upsert(room(childRoom).copy(parentChatRoomId = parentRoom))
- db.groupKeyStateDao().upsert(
- keyState(
- chatRoomId = childRoom,
- parentChatRoomId = parentRoom,
- birthCertificateJson = certificate
- )
+ db.groupKeyStateDao().replace(
+ keyState(chatRoomId = childRoom),
+ listOf(parentage(parentRoom))
)
db.chatRoomDao().delete(room(parentRoom))
@@ -146,10 +142,27 @@ class SubgroupDaoJvmTest {
// them owns.
assertNull(db.chatRoomDao().findChatRoomById(parentRoom))
assertEquals(
- parentRoom,
- db.chatRoomDao().findChatRoomById(childRoom)?.chatRoom?.parentChatRoomId
+ listOf(parentRoom),
+ db.groupKeyStateDao().getLocalByChatRoomId(childRoom)?.parents?.map { it.parentChatRoomId }
)
- assertEquals(parentRoom, db.groupKeyStateDao().getByChatRoomId(childRoom)?.parentChatRoomId)
+ }
+
+ @Test
+ fun `deleting the child takes its lineage with it`() = runBlocking {
+ seedRooms()
+
+ db.groupKeyStateDao().replace(
+ keyState(chatRoomId = childRoom),
+ listOf(parentage(parentRoom))
+ )
+
+ // The one cascade that is wanted: a lineage must not outlive the state
+ // that claimed it, or a room recreated at the same derived id would
+ // inherit a parentage nobody signed for it.
+ db.chatRoomDao().delete(room(childRoom))
+
+ assertNull(db.groupKeyStateDao().getByChatRoomId(childRoom))
+ assertEquals(emptyList(), db.groupKeyStateDao().getParentsByChatRoomId(childRoom))
}
@Test
@@ -159,73 +172,130 @@ class SubgroupDaoJvmTest {
val second = "44".repeat(32)
db.chatRoomDao().upsert(room(second))
- db.groupKeyStateDao().upsert(
- keyState(
- chatRoomId = childRoom,
- parentChatRoomId = parentRoom,
- birthCertificateJson = certificate,
- announcedAt = Instant.fromEpochSeconds(1_000)
- )
+ db.groupKeyStateDao().replace(
+ keyState(chatRoomId = childRoom, announcedAt = Instant.fromEpochSeconds(1_000)),
+ listOf(parentage(parentRoom))
)
- db.groupKeyStateDao().upsert(
- keyState(
- chatRoomId = second,
- parentChatRoomId = parentRoom,
- birthCertificateJson = certificate,
- announcedAt = Instant.fromEpochSeconds(2_000)
- )
+ db.groupKeyStateDao().replace(
+ keyState(chatRoomId = second, announcedAt = Instant.fromEpochSeconds(2_000)),
+ listOf(parentage(parentRoom, child = second))
)
// Another group's child, to prove the filter is on the parent rather
// than on having a parent at all.
- db.groupKeyStateDao().upsert(
- keyState(
- chatRoomId = parentRoom,
- parentChatRoomId = absentParent,
- birthCertificateJson = certificate
- )
+ db.groupKeyStateDao().replace(
+ keyState(chatRoomId = parentRoom),
+ listOf(parentage(absentParent, child = parentRoom))
)
assertEquals(
listOf(second, childRoom),
- db.groupKeyStateDao().getByParentChatRoomId(parentRoom).map { it.chatRoomId }
+ db.groupKeyStateDao().getByParentChatRoomId(parentRoom).map { it.keyState.chatRoomId }
)
assertEquals(
listOf(second, childRoom),
- db.groupKeyStateDao().observeByParentChatRoomId(parentRoom).first().map { it.chatRoomId }
+ db.groupKeyStateDao().observeByParentChatRoomId(parentRoom).first()
+ .map { it.keyState.chatRoomId }
)
}
@Test
- fun `a parent lists only the subgroup rooms this device actually holds`() = runBlocking {
+ fun `a child with two parents appears in both their lists`() = runBlocking {
seedRooms()
- db.chatRoomDao().upsert(room(childRoom).copy(parentChatRoomId = parentRoom))
+ db.chatRoomDao().upsert(room(absentParent))
+ db.groupKeyStateDao().replace(
+ keyState(chatRoomId = childRoom),
+ listOf(parentage(parentRoom), parentage(absentParent))
+ )
- val rooms = db.chatRoomDao().observeByParentChatRoomId(parentRoom).first()
-
- assertEquals(listOf(childRoom), rooms.map { it.chatRoom.id })
-
- // A child that was certified but whose room was never created here shows
- // up nowhere in this query. That is the difference between it and
- // SubgroupManager.subgroupsOf, which reads the parent's certificates.
- assertTrue(db.chatRoomDao().observeByParentChatRoomId(absentParent).first().isEmpty())
- }
-
- @Test
- fun `a soft-deleted subgroup drops out of its parent's list`() = runBlocking {
- seedRooms()
-
- db.chatRoomDao().upsert(room(childRoom).copy(parentChatRoomId = parentRoom))
- db.chatRoomDao().upsert(
- room(childRoom).copy(
- parentChatRoomId = parentRoom,
- deletedAt = Instant.fromEpochSeconds(9_000)
+ // The row that makes it this parent's child says nothing about who else
+ // certified it, which is the whole point of the join.
+ listOf(parentRoom, absentParent).forEach { parent ->
+ assertEquals(
+ listOf(childRoom),
+ db.groupKeyStateDao().getByParentChatRoomId(parent).map { it.keyState.chatRoomId },
+ "$parent should list the child it certified"
)
+ }
+ }
+
+ @Test
+ fun `a newer state replaces the lineage rather than adding to it`() = runBlocking {
+ seedRooms()
+
+ db.groupKeyStateDao().replace(
+ keyState(chatRoomId = childRoom, announcedAt = Instant.fromEpochSeconds(1_000)),
+ listOf(parentage(parentRoom), parentage(absentParent))
)
- // A room the user has cleared away must not come back because its parent
- // still lists it.
- assertTrue(db.chatRoomDao().observeByParentChatRoomId(parentRoom).first().isEmpty())
+ // A disavowal: the child re-announces without one of them. Were these
+ // rows merged, the dropped parent would survive the statement dropping
+ // it -- and a parent nobody can drop is a parent for life.
+ db.groupKeyStateDao().replace(
+ keyState(chatRoomId = childRoom, announcedAt = Instant.fromEpochSeconds(2_000)),
+ listOf(parentage(parentRoom))
+ )
+
+ assertEquals(
+ listOf(parentRoom),
+ db.groupKeyStateDao().getLocalByChatRoomId(childRoom)?.parents?.map { it.parentChatRoomId }
+ )
+ assertTrue(db.groupKeyStateDao().getByParentChatRoomId(absentParent).isEmpty())
+ }
+
+ @Test
+ fun `a re-announcement to nobody's child clears the lineage`() = runBlocking {
+ seedRooms()
+
+ db.groupKeyStateDao().replace(
+ keyState(chatRoomId = childRoom, announcedAt = Instant.fromEpochSeconds(1_000)),
+ listOf(parentage(parentRoom))
+ )
+ db.groupKeyStateDao().replace(
+ keyState(chatRoomId = childRoom, announcedAt = Instant.fromEpochSeconds(2_000))
+ )
+
+ assertEquals(emptyList(), db.groupKeyStateDao().getParentsByChatRoomId(childRoom))
+ }
+
+ @Test
+ fun `an older state changes neither the state nor the lineage on file`() = runBlocking {
+ seedRooms()
+
+ db.groupKeyStateDao().replace(
+ keyState(chatRoomId = childRoom, announcedAt = Instant.fromEpochSeconds(2_000)),
+ listOf(parentage(parentRoom))
+ )
+
+ // A session replayed after a crash must not walk the room back to a
+ // state it has already moved past -- and must not walk its lineage back
+ // either, which is new: the parents are part of the statement now.
+ val kept = db.groupKeyStateDao().replace(
+ keyState(chatRoomId = childRoom, announcedAt = Instant.fromEpochSeconds(1_000)),
+ listOf(parentage(absentParent))
+ )
+
+ assertEquals(Instant.fromEpochSeconds(2_000), kept.keyState.announcedAt)
+ assertEquals(listOf(parentRoom), kept.parents.map { it.parentChatRoomId })
+ assertEquals(
+ listOf(parentRoom),
+ db.groupKeyStateDao().getLocalByChatRoomId(childRoom)?.parents?.map { it.parentChatRoomId }
+ )
+ }
+
+ @Test
+ fun `one parent gets one row, whatever a caller tries`() = runBlocking {
+ seedRooms()
+
+ // The primary key is the rule "one certificate per child per parent".
+ // `SubgroupLineage.of` refuses a repeat long before here; this is the
+ // store saying so too, so the rule does not rest on a reader remembering.
+ db.groupKeyStateDao().replace(
+ keyState(chatRoomId = childRoom),
+ listOf(parentage(parentRoom), parentage(parentRoom))
+ )
+
+ assertEquals(1, db.groupKeyStateDao().getParentsByChatRoomId(childRoom).size)
}
@Test
@@ -271,10 +341,15 @@ class SubgroupDaoJvmTest {
mlsGroupState = null,
)
+ private fun parentage(parentChatRoomId: String, child: String = childRoom) =
+ GroupKeyStateParent(
+ chatRoomId = child,
+ parentChatRoomId = parentChatRoomId,
+ birthCertificateJson = certificate(parentChatRoomId),
+ )
+
private fun keyState(
chatRoomId: String,
- parentChatRoomId: String? = null,
- birthCertificateJson: String? = null,
announcedAt: Instant = Instant.fromEpochSeconds(1_700_000_000)
) = GroupKeyState(
chatRoomId = chatRoomId,
@@ -283,8 +358,6 @@ class SubgroupDaoJvmTest {
derivationPath = "m/9420/0/0",
announcedBy = user,
announcedAt = announcedAt,
- parentChatRoomId = parentChatRoomId,
- birthCertificateJson = birthCertificateJson,
)
private fun session(
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt
index 05c24f91..90bd88d6 100644
--- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt
@@ -11,6 +11,7 @@ import fr.acinq.bitcoin.PrivateKey
import fr.acinq.bitcoin.XonlyPublicKey
import fr.acinq.bitcoin.crypto.frost.Frost
import fr.acinq.bitcoin.crypto.frost.KeyMaterial
+import press.mantra.compose.nostr.subgroup.SubgroupLineage
import press.mantra.compose.nostr.subgroup.SubgroupParentage
import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent
import fr.acinq.bitcoin.crypto.frost.Session
@@ -193,7 +194,7 @@ class SignedGroupKeyStateTest {
/** The one event a session of one signs. */
suspend fun item(sessionId: String) = items(sessionId).single()
- suspend fun keyState() = db.groupKeyStateDao().getByChatRoomId(roomId)
+ suspend fun keyState() = db.groupKeyStateDao().getLocalByChatRoomId(roomId)
/** Everything the group has signed here, as the group signed it. */
suspend fun signedEvents() = db.groupSignedEventDao().getByChatRoomId(roomId)
@@ -514,13 +515,13 @@ class SignedGroupKeyStateTest {
listOf(creator, other).forEach { device ->
val state = device.keyState()
assertNotNull(state, "every signer should hold the state the group signed")
- assertEquals(thresholdPublicKey, state.thresholdPublicKey)
- assertEquals(ceremonyId, state.dkgSessionId)
- assertEquals(SharedKeyDerivation.formatPath(), state.derivationPath)
+ assertEquals(thresholdPublicKey, state.keyState.thresholdPublicKey)
+ assertEquals(ceremonyId, state.keyState.dkgSessionId)
+ assertEquals(SharedKeyDerivation.formatPath(), state.keyState.derivationPath)
// Signed by the room, so attributed to the room -- nobody in
// particular said this.
- assertEquals(adminRoomId, state.announcedBy)
- assertTrue(state.verifies())
+ assertEquals(adminRoomId, state.keyState.announcedBy)
+ assertTrue(state.keyState.verifies())
}
}
@@ -588,7 +589,7 @@ class SignedGroupKeyStateTest {
absent.keyState(),
"a member the quorum did not need still learns what the room signs with"
)
- assertEquals(adminRoomId, absent.keyState()?.announcedBy)
+ assertEquals(adminRoomId, absent.keyState()?.keyState?.announcedBy)
}
// ---- The key state, signed before the room exists ----------------------
@@ -721,22 +722,22 @@ class SignedGroupKeyStateTest {
val adopted = GroupKeyStateManager.adopt(device.db, adminRoomId)
assertNotNull(adopted, "a room should adopt the state its group signed for it")
- assertEquals(adminRoomId, adopted.chatRoomId)
- assertEquals(thresholdPublicKey, adopted.thresholdPublicKey)
- assertEquals(ceremonyId, adopted.dkgSessionId)
- assertEquals(SharedKeyDerivation.formatPath(), adopted.derivationPath)
- assertEquals(adminRoomId, adopted.announcedBy)
- assertTrue(adopted.verifies())
+ assertEquals(adminRoomId, adopted.keyState.chatRoomId)
+ assertEquals(thresholdPublicKey, adopted.keyState.thresholdPublicKey)
+ assertEquals(ceremonyId, adopted.keyState.dkgSessionId)
+ assertEquals(SharedKeyDerivation.formatPath(), adopted.keyState.derivationPath)
+ assertEquals(adminRoomId, adopted.keyState.announcedBy)
+ assertTrue(adopted.keyState.verifies())
// On file, not merely returned. Compared field by field because the
// store keeps whole seconds and the row in hand does not.
val stored = device.db.groupKeyStateDao().getByChatRoomId(adminRoomId)
assertNotNull(stored)
- assertEquals(adopted.chatRoomId, stored.chatRoomId)
- assertEquals(adopted.thresholdPublicKey, stored.thresholdPublicKey)
- assertEquals(adopted.dkgSessionId, stored.dkgSessionId)
- assertEquals(adopted.derivationPath, stored.derivationPath)
- assertEquals(adopted.announcedAt, stored.announcedAt)
+ assertEquals(adopted.keyState.chatRoomId, stored.chatRoomId)
+ assertEquals(adopted.keyState.thresholdPublicKey, stored.thresholdPublicKey)
+ assertEquals(adopted.keyState.dkgSessionId, stored.dkgSessionId)
+ assertEquals(adopted.keyState.derivationPath, stored.derivationPath)
+ assertEquals(adopted.keyState.announcedAt, stored.announcedAt)
}
}
@@ -762,14 +763,14 @@ class SignedGroupKeyStateTest {
assertNotNull(first)
assertNotNull(second)
- assertEquals(first.thresholdPublicKey, second.thresholdPublicKey)
- assertEquals(first.dkgSessionId, second.dkgSessionId)
- assertEquals(first.derivationPath, second.derivationPath)
- assertEquals(first.announcedAt, second.announcedAt)
+ assertEquals(first.keyState.thresholdPublicKey, second.keyState.thresholdPublicKey)
+ assertEquals(first.keyState.dkgSessionId, second.keyState.dkgSessionId)
+ assertEquals(first.keyState.derivationPath, second.keyState.derivationPath)
+ assertEquals(first.keyState.announcedAt, second.keyState.announcedAt)
// The second adopt is a no-op rather than a rewrite: `replace` keeps the
// state already on file when nothing newer has been signed. To whole
// seconds, which is all the store keeps.
- assertEquals(first.createdAt.epochSeconds, second.createdAt.epochSeconds)
+ assertEquals(first.keyState.createdAt.epochSeconds, second.keyState.createdAt.epochSeconds)
}
@Test
@@ -1379,9 +1380,9 @@ class SignedGroupKeyStateTest {
val state = creator.keyState()
assertNotNull(state, "a room off the default path must still reach a signed state")
- assertEquals(SharedKeyDerivation.formatPath(sibling), state.derivationPath)
- assertEquals(siblingRoomId, state.announcedBy)
- assertTrue(state.verifies())
+ assertEquals(SharedKeyDerivation.formatPath(sibling), state.keyState.derivationPath)
+ assertEquals(siblingRoomId, state.keyState.announcedBy)
+ assertTrue(state.keyState.verifies())
}
@Test
@@ -1554,9 +1555,11 @@ class SignedGroupKeyStateTest {
localChatRoom = creator.room,
userPublicKey = creator.publicKey,
key = ceremonyOn(creator),
- parent = SubgroupParentage(
- parentChatRoomId = parentRoomId,
- certificate = certificate
+ lineage = SubgroupLineage.of(
+ SubgroupParentage(
+ parentChatRoomId = parentRoomId,
+ certificate = certificate
+ )
)
)
pump(creator, other)
@@ -1569,10 +1572,11 @@ class SignedGroupKeyStateTest {
// The whole join. Neither device was sent a row; both derived the
// event from their own items, both re-ran `certifies` against the
- // parent's id, and both wrote the same two columns.
- assertEquals(parentRoomId, state.parentChatRoomId)
- assertEquals(certificate.toJson(), state.birthCertificateJson)
- assertTrue(state.verifies())
+ // parent's id, and both wrote the same parentage row.
+ assertEquals(listOf(parentRoomId), state.parentChatRoomIds())
+ assertEquals(certificate.toJson(), state.parents.single().birthCertificateJson)
+ assertEquals(adminRoomId, state.parents.single().chatRoomId)
+ assertTrue(state.keyState.verifies())
}
}
@@ -1591,9 +1595,11 @@ class SignedGroupKeyStateTest {
localChatRoom = creator.room,
userPublicKey = creator.publicKey,
key = ceremonyOn(creator),
- parent = SubgroupParentage(
- parentChatRoomId = parentRoomId,
- certificate = birthCertificate(subgroupChatRoomId = parentRoomId)
+ lineage = SubgroupLineage.of(
+ SubgroupParentage(
+ parentChatRoomId = parentRoomId,
+ certificate = birthCertificate(subgroupChatRoomId = parentRoomId)
+ )
)
)
}
@@ -1621,8 +1627,8 @@ class SignedGroupKeyStateTest {
// does every top-level group made after.
listOf(creator, other).forEach { device ->
val state = assertNotNull(device.keyState())
- assertNull(state.parentChatRoomId)
- assertNull(state.birthCertificateJson)
+ assertEquals(emptyList(), state.parents)
+ assertNull(state.lineage())
}
}
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt
index 5953e669..32ee1bd3 100644
--- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt
@@ -34,6 +34,8 @@ import press.mantra.compose.database.model.ChatRoom
import press.mantra.compose.database.model.DkgParticipantMessage
import press.mantra.compose.database.model.DkgSession
import press.mantra.compose.database.model.types.DkgRitualStage
+import press.mantra.compose.database.model.GroupKeyState
+import press.mantra.compose.database.model.GroupKeyStateParent
import press.mantra.compose.database.model.GroupSignedEvent
import press.mantra.compose.database.model.NostrEvent
import press.mantra.compose.database.model.Participant
@@ -41,6 +43,7 @@ import press.mantra.compose.database.model.Profile
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.extensions.toHex
import press.mantra.compose.nostr.dkg.DkgRitualEvents
+import press.mantra.compose.nostr.frost.GroupKeyStateEvent
import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent
/**
@@ -501,6 +504,326 @@ class SubgroupManagerJvmTest {
* room first, because `DkgSession.chatRoomId` is a foreign key onto it.
*/
/** A ceremony held in the parent's room to make a subgroup run by [admins]. */
+ // ---- Adoption: a group that already exists gaining a parent -------------
+
+ @Test
+ fun `a group with a key state and an admin in both can be adopted`() = runBlocking {
+ val adopting = parentWith(members = listOf(alice, bob))
+ childWithKeyState()
+
+ assertNull(refuseAdoptionOf(adopting))
+ }
+
+ @Test
+ fun `a group that has agreed no key state cannot be certified`() = runBlocking {
+ val adopting = parentWith(members = listOf(alice, bob))
+ // The room exists; it has never said what it signs with. The certificate
+ // carries the child's threshold key and there is none to carry, so
+ // `certifies` would fail its fourth question on every device.
+ seedRoom(childRoomId)
+
+ assertEquals(
+ "This group has not agreed what it signs with, so there is nothing to certify.",
+ refuseAdoptionOf(adopting)
+ )
+ }
+
+ @Test
+ fun `a member who holds no share of the adopting group's key is refused first`() = runBlocking {
+ val adopting = parentWith(members = listOf(alice, bob), canSign = false)
+ childWithKeyState()
+
+ assertEquals(
+ "Only a member who holds a share of that group's key can adopt a group into it.",
+ refuseAdoptionOf(adopting)
+ )
+ }
+
+ @Test
+ fun `a non-admin of the adopting group is refused`() = runBlocking {
+ val adopting = parentWith(members = listOf(alice, bob), isAdmin = false)
+ childWithKeyState()
+
+ assertEquals(
+ "Only an admin of that group can adopt a group into it.",
+ refuseAdoptionOf(adopting)
+ )
+ }
+
+ @Test
+ fun `a group cannot be its own parent`() = runBlocking {
+ val adopting = parentWith(members = listOf(alice, bob))
+ childWithKeyState()
+
+ assertEquals(
+ "A group cannot be its own parent.",
+ SubgroupManager.refuseAdoption(
+ database = db,
+ adoptingRoom = adopting,
+ childChatRoomId = adopting.chatRoom.id,
+ userPublicKey = user
+ )
+ )
+ }
+
+ @Test
+ fun `a parent already in the lineage is not offered again`() = runBlocking {
+ val adopting = parentWith(members = listOf(alice, bob))
+ childWithKeyState(parents = listOf(parentRoomId))
+
+ assertEquals("That group has already adopted this one.", refuseAdoptionOf(adopting))
+ }
+
+ @Test
+ fun `an offer that already stands is not made twice`() = runBlocking {
+ val adopting = parentWith(members = listOf(alice, bob))
+ childWithKeyState()
+
+ // One certificate per child per parent: a second is a `d`-tag
+ // replacement of the first rather than a second adoption.
+ record(certificate())
+
+ assertEquals(
+ "That group has already offered to adopt this one.",
+ refuseAdoptionOf(adopting)
+ )
+ }
+
+ @Test
+ fun `a group already beneath this one cannot adopt it`() = runBlocking {
+ val adopting = parentWith(members = listOf(alice, bob))
+ childWithKeyState()
+
+ // The adopting group is already the child's child, so adopting it back
+ // would close a cycle. Advisory -- this device sees only the lineages it
+ // holds states for -- and the real protection is the child's own quorum.
+ db.groupKeyStateDao().replace(
+ GroupKeyState(
+ chatRoomId = parentRoomId,
+ dkgSessionId = "parent-ceremony",
+ thresholdPublicKey = parentMaterial.thresholdPublicKey.value.toHex(),
+ derivationPath = SharedKeyDerivation.formatPath(path),
+ announcedBy = parentRoomId,
+ announcedAt = Instant.fromEpochSeconds(1_700_000_000),
+ ),
+ listOf(
+ GroupKeyStateParent(
+ chatRoomId = parentRoomId,
+ parentChatRoomId = childRoomId,
+ birthCertificateJson = certificate().toJson(),
+ )
+ )
+ )
+
+ assertEquals(
+ "That group is already a subgroup of this one.",
+ refuseAdoptionOf(adopting)
+ )
+ }
+
+ @Test
+ fun `the picker offers only the rooms this profile can act in`() = runBlocking {
+ val adopting = parentWith(members = listOf(alice, bob))
+ childWithKeyState()
+
+ // A room this device is in but holds no share of, and one it is in but
+ // does not administer. Neither can carry the signature, so neither is
+ // offered -- and the filter runs the guard itself rather than a copy of
+ // its rules.
+ marmotRoom(
+ chatRoomId = siblingRoomId,
+ material = siblingMaterial,
+ ceremonyId = "sibling-ceremony",
+ name = "Elders",
+ members = listOf(alice),
+ canSign = false,
+ )
+
+ assertEquals(
+ listOf(adopting.chatRoom.id),
+ SubgroupManager.adoptingCandidatesFor(db, childRoomId, user).map { it.chatRoom.id }
+ )
+ }
+
+ @Test
+ fun `accepting a parent re-announces the key state with it added`() = runBlocking {
+ parentWith(members = listOf(alice, bob))
+ val child = childWithKeyState()
+ record(certificate())
+
+ val session = SubgroupManager.proposeAcceptance(
+ database = db,
+ childRoom = child,
+ userPublicKey = user,
+ parentChatRoomId = parentRoomId
+ )
+
+ // Signed in the child's own room, which founding cannot do: the room
+ // exists, so `propose`'s first branch holds.
+ assertEquals(childRoomId, session.chatRoomId)
+
+ val proposed = Event.fromJson(
+ assertNotNull(db.frostSigningSessionDao().getItems(session.id).singleOrNull())
+ .unsignedEventJson
+ )
+ assertEquals(GroupKeyStateEvent.KIND, proposed.kind)
+ assertEquals(
+ listOf(parentRoomId),
+ GroupKeyStateEvent.parseLineage(proposed.tags)?.parentChatRoomIds
+ )
+ }
+
+ @Test
+ fun `accepting a second parent carries the first one too`() = runBlocking {
+ parentWith(members = listOf(alice, bob))
+ val child = childWithKeyState(parents = listOf(siblingRoomId))
+ record(certificate())
+
+ val session = SubgroupManager.proposeAcceptance(
+ database = db,
+ childRoom = child,
+ userPublicKey = user,
+ parentChatRoomId = parentRoomId
+ )
+
+ // The footgun this guards: a key state is replaceable and the newest wins
+ // wholesale, so a state naming only the new parent would silently drop
+ // the one already there.
+ val proposed = Event.fromJson(
+ assertNotNull(db.frostSigningSessionDao().getItems(session.id).singleOrNull())
+ .unsignedEventJson
+ )
+ assertEquals(
+ listOf(parentRoomId, siblingRoomId).sorted(),
+ GroupKeyStateEvent.parseLineage(proposed.tags)?.parentChatRoomIds?.sorted()
+ )
+ }
+
+ @Test
+ fun `accepting a parent that has not certified this group is refused`() = runBlocking {
+ parentWith(members = listOf(alice, bob))
+ val child = childWithKeyState()
+
+ // No certificate on file. Only a member of the adopting group has one, so
+ // this is the overlap requirement from the other side.
+ assertFailsWith {
+ SubgroupManager.proposeAcceptance(
+ database = db,
+ childRoom = child,
+ userPublicKey = user,
+ parentChatRoomId = parentRoomId
+ )
+ }
+ Unit
+ }
+
+ @Test
+ fun `disavowing a parent re-announces the key state without it`() = runBlocking {
+ parentWith(members = listOf(alice, bob))
+ val child = childWithKeyState(parents = listOf(parentRoomId, siblingRoomId))
+
+ val session = SubgroupManager.proposeDisavowal(
+ database = db,
+ childRoom = child,
+ userPublicKey = user,
+ parentChatRoomId = parentRoomId
+ )
+
+ val proposed = Event.fromJson(
+ assertNotNull(db.frostSigningSessionDao().getItems(session.id).singleOrNull())
+ .unsignedEventJson
+ )
+ assertEquals(
+ listOf(siblingRoomId),
+ GroupKeyStateEvent.parseLineage(proposed.tags)?.parentChatRoomIds
+ )
+ }
+
+ @Test
+ fun `disavowing the only parent leaves a state claiming none`() = runBlocking {
+ parentWith(members = listOf(alice, bob))
+ val child = childWithKeyState(parents = listOf(parentRoomId))
+
+ val session = SubgroupManager.proposeDisavowal(
+ database = db,
+ childRoom = child,
+ userPublicKey = user,
+ parentChatRoomId = parentRoomId
+ )
+
+ val proposed = Event.fromJson(
+ assertNotNull(db.frostSigningSessionDao().getItems(session.id).singleOrNull())
+ .unsignedEventJson
+ )
+ // Nobody's child, which is a state rather than an absence of one. It
+ // revokes nothing: the certificate is a signature and exists forever.
+ assertFalse(GroupKeyStateEvent.claimsParentage(proposed.tags))
+ assertNull(GroupKeyStateEvent.parseLineage(proposed.tags))
+ }
+
+ @Test
+ fun `disavowing a parent this group never claimed is refused`() = runBlocking {
+ parentWith(members = listOf(alice, bob))
+ val child = childWithKeyState(parents = listOf(siblingRoomId))
+
+ assertFailsWith {
+ SubgroupManager.proposeDisavowal(
+ database = db,
+ childRoom = child,
+ userPublicKey = user,
+ parentChatRoomId = parentRoomId
+ )
+ }
+ Unit
+ }
+
+ private suspend fun refuseAdoptionOf(adopting: LocalChatRoom): String? =
+ SubgroupManager.refuseAdoption(
+ database = db,
+ adoptingRoom = adopting,
+ childChatRoomId = childRoomId,
+ userPublicKey = user
+ )
+
+ /**
+ * The child as an adoption finds it: a real Marmot room of its own, with a
+ * key state saying what it signs with, and whatever lineage it already has.
+ */
+ private suspend fun childWithKeyState(
+ parents: List = emptyList()
+ ): LocalChatRoom {
+ val room = marmotRoom(
+ chatRoomId = childRoomId,
+ material = childMaterial,
+ ceremonyId = "child-ceremony",
+ name = "Translators",
+ members = listOf(alice, bob),
+ )
+
+ db.groupKeyStateDao().replace(
+ GroupKeyState(
+ chatRoomId = childRoomId,
+ dkgSessionId = "child-ceremony",
+ thresholdPublicKey = childMaterial.thresholdPublicKey.value.toHex(),
+ derivationPath = SharedKeyDerivation.formatPath(path),
+ announcedBy = childRoomId,
+ announcedAt = Instant.fromEpochSeconds(1_700_000_000),
+ ),
+ parents.map { parent ->
+ GroupKeyStateParent(
+ chatRoomId = childRoomId,
+ parentChatRoomId = parent,
+ birthCertificateJson = certificate(
+ author = if (parent == parentRoomId) parentMaterial else siblingMaterial,
+ parentChatRoomId = parent,
+ ).toJson(),
+ )
+ }
+ )
+
+ return room
+ }
+
private fun subgroupCeremony(id: String, admins: Set) =
ownCeremony(parentRoomId).copy(
id = id,
@@ -584,15 +907,40 @@ class SubgroupManagerJvmTest {
members: List,
canSign: Boolean = true,
isAdmin: Boolean = true,
+ ): LocalChatRoom = marmotRoom(
+ chatRoomId = parentRoomId,
+ material = parentMaterial,
+ ceremonyId = "parent-ceremony",
+ name = "Ekklesia",
+ members = members,
+ canSign = canSign,
+ isAdmin = isAdmin,
+ )
+
+ /**
+ * Any of this app's group rooms, as what one *is*: the admin room derived
+ * from the group's own key, with its `MarmotGroupData` in the epoch-0
+ * extensions. Adoption needs two of them at once -- the adopting group and
+ * the child -- so this takes the id and the key rather than closing over
+ * the parent's.
+ */
+ private suspend fun marmotRoom(
+ chatRoomId: String,
+ material: KeyMaterial,
+ ceremonyId: String,
+ name: String,
+ members: List,
+ canSign: Boolean = true,
+ isAdmin: Boolean = true,
): LocalChatRoom {
(members + user).forEach { seedProfile(it) }
val admins = if (isAdmin) members + user else members
val metadata = MarmotGroupData(
- nostrGroupId = parentRoomId,
- name = "Ekklesia",
- description = SharedKeyDerivation.describe("Admins of Ekklesia."),
+ nostrGroupId = chatRoomId,
+ name = name,
+ description = SharedKeyDerivation.describe("Admins of $name."),
adminPubkeys = admins,
relays = Relays.DefaultDMRelayList.map { it.url }
)
@@ -604,9 +952,9 @@ class SubgroupManagerJvmTest {
db.chatRoomDao().upsert(
ChatRoom(
- id = parentRoomId,
+ id = chatRoomId,
userPublicKey = user,
- subject = "Ekklesia",
+ subject = name,
description = metadata.description,
mlsGroupState = mlsGroup.saveState().encodeTls().toHex(),
)
@@ -620,7 +968,7 @@ class SubgroupManagerJvmTest {
(members + user).map {
Participant(
participantPublicKey = it,
- chatRoomId = parentRoomId,
+ chatRoomId = chatRoomId,
relayHint = null,
)
}
@@ -631,8 +979,8 @@ class SubgroupManagerJvmTest {
// matches, which is exactly how a real admin room resolves its key.
db.dkgSessionDao().upsert(
DkgSession(
- id = "parent-ceremony",
- chatRoomId = parentRoomId,
+ id = ceremonyId,
+ chatRoomId = chatRoomId,
coordinatorPublicKey = user,
userPublicKey = user,
threshold = 2,
@@ -641,7 +989,7 @@ class SubgroupManagerJvmTest {
hostPublicKey = user,
round1Random = "aa".repeat(32),
round2AuxRandom = "bb".repeat(32),
- thresholdPublicKey = parentMaterial.thresholdPublicKey.value.toHex(),
+ thresholdPublicKey = material.thresholdPublicKey.value.toHex(),
secretShare = "cc".repeat(32),
)
)
@@ -653,7 +1001,7 @@ class SubgroupManagerJvmTest {
(members + user).forEachIndexed { index, member ->
db.dkgSessionDao().upsert(
DkgParticipantMessage(
- sessionId = "parent-ceremony",
+ sessionId = ceremonyId,
participantPublicKey = member,
kind = DkgRitualEvents.HOST_KEY,
payload = "0${index + 1}".repeat(33)
@@ -662,7 +1010,7 @@ class SubgroupManagerJvmTest {
}
}
- return assertNotNull(db.chatRoomDao().findChatRoomById(parentRoomId))
+ return assertNotNull(db.chatRoomDao().findChatRoomById(chatRoomId))
}
private suspend fun seedProfile(publicKey: String) {
@@ -716,12 +1064,13 @@ class SubgroupManagerJvmTest {
createdAt: Long = 1_700_000_000,
author: KeyMaterial = parentMaterial,
signer: KeyMaterial = author,
- signature: String? = null
+ signature: String? = null,
+ parentChatRoomId: String = roomOf(author)
): Event {
val subgroupChatRoomId = roomOf(subgroupMaterial)
val eventTags = tags ?: SubgroupBirthCertificateEvent.assembleTags(
subgroupChatRoomId = subgroupChatRoomId,
- parentChatRoomId = parentRoomId,
+ parentChatRoomId = parentChatRoomId,
thresholdPublicKey = subgroupMaterial.thresholdPublicKey.value.toHex(),
adminPublicKeys = adminPublicKeys,
name = name,
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/DkgRitualListScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/DkgRitualListScreenJvmTest.kt
new file mode 100644
index 00000000..7d49853d
--- /dev/null
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/DkgRitualListScreenJvmTest.kt
@@ -0,0 +1,157 @@
+package press.mantra.compose.ui.composable
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.runDesktopComposeUiTest
+import press.mantra.compose.database.model.ChatRoom
+import press.mantra.compose.database.model.DkgSession
+import press.mantra.compose.database.model.intermdiate.LocalChatRoom
+import press.mantra.compose.database.model.types.DkgApprovalStep
+import press.mantra.compose.database.model.types.DkgRitualStage
+import press.mantra.compose.repository.ChatRepository
+import press.mantra.compose.repository.DkgRepository
+import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute
+import press.mantra.compose.ui.composable.navigation.routes.Route
+import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost
+import press.mantra.compose.ui.theme.MantraTheme
+import press.mantra.compose.ui.view.state.DkgRitualListUIState
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+/**
+ * What the ceremony list is for, read off the rendered screen.
+ *
+ * The ordering is settled in [press.mantra.compose.ui.view.model.DkgRitualListViewModel]
+ * and tested there. What is only true once it is drawn is that a row goes to
+ * the ceremony it names -- the whole reason this screen exists is that the
+ * screens before it could not say which of a room's ceremonies they meant --
+ * and that a room with none offers the way to hold one rather than ending in a
+ * blank page.
+ */
+@OptIn(ExperimentalTestApi::class)
+class DkgRitualListScreenJvmTest {
+ private val me = "11".repeat(32)
+ private val chatRoomId = "22".repeat(32)
+
+ @Test
+ fun `a row opens the ceremony it names, not the room's newest`() =
+ runDesktopComposeUiTest(500, 1200) {
+ var opened: Route? = null
+
+ setContent {
+ Screen(
+ state = loaded(
+ ceremony("subgroup", DkgRitualStage.COLLECTING_HOST_KEYS).copy(
+ pendingApproval = DkgApprovalStep.HOST_KEY
+ ),
+ ceremony("newest", DkgRitualStage.COMPLETE)
+ ),
+ onNavigateToRoute = { opened = it }
+ )
+ }
+
+ onNodeWithText("Join it by publishing your device's key")
+ .assertIsDisplayed()
+ .performClick()
+
+ assertEquals(
+ DkgRitualRoute(
+ activeUserPublicKey = me,
+ chatRoomId = chatRoomId,
+ dkgSessionId = "subgroup"
+ ),
+ opened
+ )
+ }
+
+ /** The two sections, so a ceremony owing nothing cannot be mistaken for one that does. */
+ @Test
+ fun `the ones waiting on you are headed apart from the rest`() =
+ runDesktopComposeUiTest(500, 1200) {
+ setContent {
+ Screen(
+ state = loaded(
+ ceremony("subgroup", DkgRitualStage.COLLECTING_HOST_KEYS).copy(
+ pendingApproval = DkgApprovalStep.HOST_KEY
+ ),
+ ceremony("newest", DkgRitualStage.COMPLETE)
+ )
+ )
+ }
+
+ onNodeWithText("Waiting for you").assertIsDisplayed()
+ onNodeWithText("Everything else").assertIsDisplayed()
+ onNodeWithText("The group has a key from this ceremony.").assertIsDisplayed()
+ }
+
+ @Test
+ fun `a room that has held none offers to hold one`() =
+ runDesktopComposeUiTest(500, 1200) {
+ var opened: Route? = null
+
+ setContent { Screen(state = loaded(), onNavigateToRoute = { opened = it }) }
+
+ onNodeWithText("This group has not held a key ceremony.").assertIsDisplayed()
+ onNodeWithText("Set up a shared key").assertIsDisplayed().performClick()
+
+ // No session id: there is no ceremony to name, and the ceremony
+ // screen is what decides whether the group can hold one at all.
+ assertEquals(
+ DkgRitualRoute(activeUserPublicKey = me, chatRoomId = chatRoomId),
+ opened
+ )
+ }
+
+ @Composable
+ private fun Screen(
+ state: DkgRitualListUIState,
+ onNavigateToRoute: (Route) -> Unit = {},
+ ) {
+ MantraTheme {
+ ProvideSnackbarHost {
+ DkgRitualListScreen(
+ activeUserPublicKey = me,
+ chatRoomId = chatRoomId,
+ initialDkgRitualListUIState = state,
+ chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
+ dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY,
+ onNavigateBack = {},
+ onNavigateToRoute = onNavigateToRoute,
+ )
+ }
+ }
+ }
+
+ private fun loaded(vararg ceremonies: DkgRitualListUIState.Ceremony) =
+ DkgRitualListUIState.Loaded(
+ localChatRoom = LocalChatRoom(
+ chatRoom = ChatRoom(
+ id = chatRoomId,
+ userPublicKey = me,
+ subject = "Ekklesia",
+ description = null,
+ mlsGroupState = null,
+ )
+ ),
+ ceremonies = ceremonies.toList()
+ )
+
+ private fun ceremony(id: String, stage: DkgRitualStage) = DkgRitualListUIState.Ceremony(
+ session = DkgSession(
+ id = id,
+ chatRoomId = chatRoomId,
+ coordinatorPublicKey = me,
+ userPublicKey = me,
+ threshold = 2,
+ participantCount = 2,
+ hostPublicKey = me,
+ round1Random = "66".repeat(32),
+ round2AuxRandom = "77".repeat(32),
+ stage = stage,
+ ),
+ coordinatorName = "You",
+ )
+}
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/CeremonyQueueRoutingJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/CeremonyQueueRoutingJvmTest.kt
new file mode 100644
index 00000000..f6c876cb
--- /dev/null
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/CeremonyQueueRoutingJvmTest.kt
@@ -0,0 +1,216 @@
+package press.mantra.compose.ui.view.model
+
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+import kotlinx.coroutines.withTimeout
+import kotlinx.coroutines.delay
+import press.mantra.compose.database.model.ChatRoom
+import press.mantra.compose.database.model.DkgSession
+import press.mantra.compose.database.model.Participant
+import press.mantra.compose.database.model.intermdiate.LocalChatRoom
+import press.mantra.compose.database.model.intermdiate.LocalParticipant
+import press.mantra.compose.database.model.types.DkgApprovalStep
+import press.mantra.compose.database.model.types.DkgRitualStage
+import press.mantra.compose.repository.ChatRepository
+import press.mantra.compose.repository.DkgRepository
+import press.mantra.compose.repository.FrostSigningRepository
+import press.mantra.compose.repository.NostrRepository
+import press.mantra.compose.ui.view.state.ChatMessageListUIState
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+/**
+ * When tapping one ceremony in the transcript is answering the whole question,
+ * and when it is answering a third of it.
+ *
+ * The transcript names one ceremony per line and its standing notice stands for
+ * however many are owed. Opening the named one is right while it is the only
+ * thing being asked; it stops being right the moment there are two, because the
+ * second is not on screen beside the first -- it may be pages up, or have
+ * arrived while the member was reading -- so answering the one they tapped and
+ * leaving would look exactly like being done.
+ *
+ * `hidesOtherDecisions` has made this call for proposals since before subgroups
+ * existed. [ChatMessageListViewModel.hidesOtherCeremonies] is its counterpart,
+ * and a room holds several ceremonies as a matter of course now that a
+ * subgroup's ChillDKG runs in its parent's room.
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+class CeremonyQueueRoutingJvmTest {
+ private val chatRoomId = "aa".repeat(32)
+ private val me = "11".repeat(32)
+ private val peer = "22".repeat(32)
+
+ @BeforeTest
+ fun setUp() {
+ Dispatchers.setMain(UnconfinedTestDispatcher())
+ }
+
+ @AfterTest
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun `one ceremony owed is answered by opening it`() = runBlocking {
+ val viewModel = awaiting(
+ sessions = listOf(session("only", DkgRitualStage.COLLECTING_ROUND_1)),
+ pending = mapOf("only" to DkgApprovalStep.ROUND_1),
+ expected = 1
+ )
+
+ assertFalse(
+ viewModel.hidesOtherCeremonies("only"),
+ "the one ceremony owed was sent to a queue of itself"
+ )
+ }
+
+ @Test
+ fun `two ceremonies owed send the member to the list instead`() = runBlocking {
+ val viewModel = awaiting(
+ sessions = listOf(
+ session("ours", DkgRitualStage.COLLECTING_ROUND_1),
+ session("subgroup", DkgRitualStage.COLLECTING_HOST_KEYS)
+ .copy(parentChatRoomId = chatRoomId, subject = "Translators"),
+ ),
+ pending = mapOf(
+ "ours" to DkgApprovalStep.ROUND_1,
+ "subgroup" to DkgApprovalStep.HOST_KEY
+ ),
+ expected = 2
+ )
+
+ // Either tap: the queue is the honest answer to both, because neither is
+ // the whole of what the room is waiting on this member for.
+ assertTrue(viewModel.hidesOtherCeremonies("ours"))
+ assertTrue(viewModel.hidesOtherCeremonies("subgroup"))
+ }
+
+ /**
+ * The restraint, and the reason this is not just "does the room owe two".
+ * A line about a ceremony the group finished is history, and a reader who
+ * taps one is asking to see that -- not to be handed a queue of other
+ * people's business.
+ */
+ @Test
+ fun `a finished ceremony is opened on its own even while others are owed`() = runBlocking {
+ val viewModel = awaiting(
+ sessions = listOf(
+ session("done", DkgRitualStage.COMPLETE),
+ session("ours", DkgRitualStage.COLLECTING_ROUND_1),
+ session("subgroup", DkgRitualStage.COLLECTING_HOST_KEYS),
+ ),
+ pending = mapOf(
+ "ours" to DkgApprovalStep.ROUND_1,
+ "subgroup" to DkgApprovalStep.HOST_KEY
+ ),
+ expected = 2
+ )
+
+ assertFalse(
+ viewModel.hidesOtherCeremonies("done"),
+ "a finished ceremony's line was answered with somebody else's queue"
+ )
+ }
+
+ /**
+ * An abandoned one is not owed either, which matters because the room's
+ * watcher filters on it and a row that slipped through would put a dead
+ * ceremony at the top of the notice.
+ */
+ @Test
+ fun `an abandoned ceremony is never counted as owed`() = runBlocking {
+ val viewModel = awaiting(
+ sessions = listOf(session("abandoned", DkgRitualStage.FAILED)),
+ // Says yes, and has to be ignored on a failure.
+ pending = mapOf("abandoned" to DkgApprovalStep.ROUND_1),
+ expected = 0
+ )
+
+ assertTrue(viewModel.ceremoniesAwaitingYou.isEmpty())
+ assertFalse(viewModel.hidesOtherCeremonies("abandoned"))
+ }
+
+ /** Builds the view model and waits until its watcher has settled on [expected]. */
+ private suspend fun awaiting(
+ sessions: List,
+ pending: Map,
+ expected: Int,
+ ): ChatMessageListViewModel {
+ val viewModel = ChatMessageListViewModel(
+ initialChatMessageListUIState = ChatMessageListUIState.Loading,
+ localChatRoom = room(),
+ nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
+ chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
+ frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
+ dkgRepository = object : DkgRepository by DkgRepository.NO_OP_DKG_REPOSITORY {
+ override fun observeSessionsForChatRoom(
+ chatRoomId: String
+ ): Flow> = flowOf(sessions)
+
+ override suspend fun pendingApproval(session: DkgSession): DkgApprovalStep? =
+ pending[session.id]
+ }
+ )
+
+ viewModel.initiate()
+
+ // The watcher writes once, so an empty expectation cannot be waited for
+ // by its own value -- it is indistinguishable from not having run. Wait
+ // for a non-empty one, and give an empty one a moment to be wrong in.
+ withTimeout(5_000) {
+ if (expected == 0) {
+ delay(200)
+ } else {
+ while (viewModel.ceremoniesAwaitingYou.size != expected) delay(5)
+ }
+ }
+
+ assertEquals(expected, viewModel.ceremoniesAwaitingYou.size)
+ return viewModel
+ }
+
+ private fun room() = LocalChatRoom(
+ chatRoom = ChatRoom(
+ id = chatRoomId,
+ userPublicKey = me,
+ subject = "Ekklesia",
+ description = null,
+ mlsGroupState = null,
+ ),
+ localParticipants = listOf(me, peer).map { publicKey ->
+ LocalParticipant(
+ participant = Participant(
+ participantPublicKey = publicKey,
+ chatRoomId = chatRoomId,
+ relayHint = null,
+ ),
+ profile = null,
+ )
+ },
+ )
+
+ private fun session(id: String, stage: DkgRitualStage) = DkgSession(
+ id = id,
+ chatRoomId = chatRoomId,
+ coordinatorPublicKey = peer,
+ userPublicKey = me,
+ threshold = 2,
+ participantCount = 2,
+ hostPublicKey = peer,
+ round1Random = "66".repeat(32),
+ round2AuxRandom = "77".repeat(32),
+ stage = stage,
+ )
+}
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualListViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualListViewModelJvmTest.kt
new file mode 100644
index 00000000..80798701
--- /dev/null
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualListViewModelJvmTest.kt
@@ -0,0 +1,198 @@
+package press.mantra.compose.ui.view.model
+
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+import kotlinx.coroutines.withTimeout
+import press.mantra.compose.database.model.ChatRoom
+import press.mantra.compose.database.model.DkgSession
+import press.mantra.compose.database.model.Participant
+import press.mantra.compose.database.model.Profile
+import press.mantra.compose.database.model.intermdiate.LocalChatRoom
+import press.mantra.compose.database.model.intermdiate.LocalParticipant
+import press.mantra.compose.database.model.types.DkgApprovalStep
+import press.mantra.compose.database.model.types.DkgRitualStage
+import press.mantra.compose.repository.ChatRepository
+import press.mantra.compose.repository.DkgRepository
+import press.mantra.compose.ui.view.state.DkgRitualListUIState
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+/**
+ * What the key-ceremony list decides that nothing else can check.
+ *
+ * **Which ceremonies come first.** A room holds one per subgroup the group has
+ * ever made plus its own, and the newest is routinely the finished one -- so
+ * the list is only worth having if the two that want something from the reader
+ * are at the top of it rather than wherever their dates put them.
+ *
+ * **What counts as wanting something.** Two different things do, owed by
+ * different people: an approval gate this member cannot delegate, and a
+ * finished ceremony whose room only its coordinator can create. An abandoned
+ * ceremony wants neither, whatever the rows underneath it say.
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+class DkgRitualListViewModelJvmTest {
+ private val chatRoomId = "a1b2c3d4".repeat(8)
+ private val me = "11".repeat(32)
+ private val ada = "22".repeat(32)
+
+ @BeforeTest
+ fun setUp() {
+ Dispatchers.setMain(UnconfinedTestDispatcher())
+ }
+
+ @AfterTest
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun `the ceremonies waiting on this member are separated from the rest`() = runBlocking {
+ val mine = session("mine", DkgRitualStage.COLLECTING_ROUND_1)
+ val finished = session("finished", DkgRitualStage.COMPLETE)
+ val subgroup = session("subgroup", DkgRitualStage.COLLECTING_HOST_KEYS)
+ .copy(parentChatRoomId = "parent", subject = "Translators")
+
+ val loaded = load(
+ // Newest first, the way the query returns them -- and the one that
+ // wants something is deliberately not the newest.
+ sessions = listOf(finished, subgroup, mine),
+ pending = mapOf(
+ "mine" to DkgApprovalStep.ROUND_1,
+ "subgroup" to DkgApprovalStep.HOST_KEY
+ )
+ )
+
+ assertEquals(
+ listOf("subgroup", "mine"),
+ loaded.waitingForYou.map { it.session.id },
+ "a ceremony waiting on this member was left down the list"
+ )
+ assertEquals(listOf("finished"), loaded.rest.map { it.session.id })
+ assertTrue(loaded.waitingForYou.single { it.session.id == "subgroup" }.isSubgroup)
+ }
+
+ /**
+ * A finished ceremony still owes its coordinator a room, and owes everybody
+ * else nothing -- a row telling the rest of the group to create it would be
+ * telling them to do something they cannot.
+ */
+ @Test
+ fun `only the coordinator is told to finish setting up a finished ceremony`() = runBlocking {
+ val theirs = session("theirs", DkgRitualStage.COMPLETE).copy(coordinatorPublicKey = ada)
+ val ours = session("ours", DkgRitualStage.COMPLETE)
+
+ val loaded = load(
+ sessions = listOf(theirs, ours),
+ roomAwaitingCreation = "theRoomItDerives"
+ )
+
+ assertEquals(listOf("ours"), loaded.waitingForYou.map { it.session.id })
+ assertEquals("Ada", loaded.rest.single().coordinatorName)
+ assertEquals("You", loaded.waitingForYou.single().coordinatorName)
+ }
+
+ /**
+ * An abandoned ceremony is asked nothing at all. There is no step left to
+ * approve and no key to build a room from, so a row that claimed otherwise
+ * would send a member to a screen with nothing on it.
+ */
+ @Test
+ fun `an abandoned ceremony is never waiting on anybody`() = runBlocking {
+ val loaded = load(
+ sessions = listOf(session("abandoned", DkgRitualStage.FAILED)),
+ // Both answers say yes, and both have to be ignored on a failure.
+ pending = mapOf("abandoned" to DkgApprovalStep.ROUND_1),
+ roomAwaitingCreation = "theRoomItDerives"
+ )
+
+ assertTrue(loaded.waitingForYou.isEmpty())
+
+ val row = loaded.rest.single()
+ assertNull(row.pendingApproval)
+ assertNull(row.roomAwaitingCreation)
+ }
+
+ private suspend fun load(
+ sessions: List,
+ pending: Map = emptyMap(),
+ roomAwaitingCreation: String? = null,
+ ): DkgRitualListUIState.Loaded {
+ val viewModel = DkgRitualListViewModel(
+ chatRoomId = chatRoomId,
+ activeUserPublicKey = me,
+ initialDkgRitualListUIState = DkgRitualListUIState.Loading,
+ chatRepository = object : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY {
+ override suspend fun getChatRoomByIdentifier(id: HexKey): LocalChatRoom? = room()
+ },
+ dkgRepository = object : DkgRepository by DkgRepository.NO_OP_DKG_REPOSITORY {
+ override fun observeSessionsForChatRoom(
+ chatRoomId: String
+ ): Flow> = flowOf(sessions)
+
+ override suspend fun pendingApproval(session: DkgSession): DkgApprovalStep? =
+ pending[session.id]
+
+ override suspend fun roomAwaitingCreation(session: DkgSession): String? =
+ roomAwaitingCreation
+ }
+ )
+
+ viewModel.initiate()
+
+ return withTimeout(5_000) {
+ viewModel.dkgRitualListUIState.first { it is DkgRitualListUIState.Loaded }
+ } as DkgRitualListUIState.Loaded
+ }
+
+ private fun room() = LocalChatRoom(
+ chatRoom = ChatRoom(
+ id = chatRoomId,
+ userPublicKey = me,
+ subject = "Ekklesia",
+ description = null,
+ mlsGroupState = null,
+ ),
+ localParticipants = listOf(me to null, ada to "Ada").map { (publicKey, name) ->
+ LocalParticipant(
+ participant = Participant(
+ participantPublicKey = publicKey,
+ chatRoomId = chatRoomId,
+ relayHint = null,
+ ),
+ profile = name?.let {
+ Profile(
+ publicKey = publicKey,
+ displayName = it,
+ nostrEventId = "ee".repeat(32),
+ )
+ },
+ )
+ },
+ )
+
+ private fun session(id: String, stage: DkgRitualStage) = DkgSession(
+ id = id,
+ chatRoomId = chatRoomId,
+ coordinatorPublicKey = me,
+ userPublicKey = me,
+ threshold = 2,
+ participantCount = 2,
+ hostPublicKey = me,
+ round1Random = "66".repeat(32),
+ round2AuxRandom = "77".repeat(32),
+ stage = stage,
+ )
+}
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualSecondSubgroupJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualSecondSubgroupJvmTest.kt
new file mode 100644
index 00000000..84815488
--- /dev/null
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualSecondSubgroupJvmTest.kt
@@ -0,0 +1,322 @@
+package press.mantra.compose.ui.view.model
+
+import com.vitorpamplona.quartz.nip01Core.core.Event
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import com.vitorpamplona.quartz.nip01Core.core.toHexKey
+import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+import kotlinx.coroutines.withTimeout
+import press.mantra.compose.database.model.ChatRoom
+import press.mantra.compose.database.model.DkgSession
+import press.mantra.compose.database.model.FrostSigningItem
+import press.mantra.compose.database.model.FrostSigningSession
+import press.mantra.compose.database.model.Participant
+import press.mantra.compose.database.model.intermdiate.LocalChatRoom
+import press.mantra.compose.database.model.intermdiate.LocalFrostSigningSession
+import press.mantra.compose.database.model.intermdiate.LocalParticipant
+import press.mantra.compose.database.model.types.DkgRitualStage
+import press.mantra.compose.database.model.types.FrostSigningStage
+import press.mantra.compose.nostr.frost.GroupKeyStateEvent
+import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent
+import press.mantra.compose.repository.ChatRepository
+import press.mantra.compose.repository.DkgRepository
+import press.mantra.compose.ui.view.state.DkgRitualUIState
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+/**
+ * A parent's room holds every one of its subgroups' signings, and the ceremony
+ * screen has to pick out the one it is showing.
+ *
+ * Since a subgroup's ChillDKG moved into its parent's Marmot room, both of the
+ * rungs after the ceremony run there too: the birth certificate is the parent's
+ * signature, and the key state is signed over the parent's room because the
+ * subgroup's own room does not exist yet. So a parent making a second subgroup
+ * has two of each in one room, told apart by nothing but the ceremony they
+ * belong to.
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+class DkgRitualSecondSubgroupJvmTest {
+ private val parentChatRoomId = "aa".repeat(32)
+ private val me = "11".repeat(32)
+ private val peer = "22".repeat(32)
+
+ // Real points, in the compressed form `SharedKeyDerivation` reads: it derives
+ // a room id off the curve, and a made-up 32 bytes is not on it.
+ private val firstKey = "02" + KeyPair().pubKey.toHexKey()
+ private val secondKey = "02" + KeyPair().pubKey.toHexKey()
+
+ @BeforeTest
+ fun setUp() {
+ Dispatchers.setMain(UnconfinedTestDispatcher())
+ }
+
+ @AfterTest
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun `the certificate shown is this subgroup's, not the parent's newest`() = runBlocking {
+ val loaded = load(
+ signingSessions = listOf(
+ // Newest first, the way the query returns them.
+ certificateSigning("forSecond", subgroupOf(secondKey)),
+ certificateSigning("forFirst", subgroupOf(firstKey)),
+ ),
+ until = { it.certificateSession != null }
+ )
+
+ assertEquals(
+ "forFirst",
+ loaded.certificateSession?.id,
+ "the screen for one subgroup is following another subgroup's certificate"
+ )
+ }
+
+ @Test
+ fun `the key state shown is this ceremony's, not the room's newest`() = runBlocking {
+ val loaded = load(
+ signingSessions = listOf(
+ keyStateSigning("forSecond", dkgSessionId = "secondCeremony"),
+ keyStateSigning("forFirst", dkgSessionId = "firstCeremony"),
+ ),
+ until = { it.keyStateSession != null }
+ )
+
+ assertEquals(
+ "forFirst",
+ loaded.keyStateSession?.id,
+ "the screen for one ceremony is following another ceremony's key state"
+ )
+ }
+
+ /**
+ * The damaging half, and the reason this is a block rather than a cosmetic
+ * mix-up. Both rungs disable their button while a signing of that kind is
+ * running and un-failed, and a *completed* signing is un-failed forever --
+ * so by kind alone, a parent that had certified one subgroup could never be
+ * asked to certify another, and the rung sat there disabled with no way out.
+ */
+ @Test
+ fun `a sibling's finished signings leave this ceremony's rungs open`() = runBlocking {
+ // Driven in two emissions rather than one, so the assertion is about a
+ // watcher that demonstrably ran. Asserting "still null" against a single
+ // emission passes before the watcher has started, which is how a version
+ // of this test passed against the bug it was written for.
+ val signingSessions = MutableStateFlow(
+ listOf(
+ certificateSigning("mine", subgroupOf(firstKey)),
+ keyStateSigning("myKeyState", dkgSessionId = "firstCeremony"),
+ )
+ )
+
+ val viewModel = viewModel(signingSessions)
+ viewModel.initiate()
+
+ awaitLoaded(viewModel) {
+ it.certificateSession?.id == "mine" && it.keyStateSession?.id == "myKeyState"
+ }
+
+ // Now the room holds only a sibling's, both finished -- which is every
+ // parent that has already made one subgroup and is making its second.
+ signingSessions.value = listOf(
+ certificateSigning(
+ "siblingCertificate",
+ subgroupOf(secondKey),
+ stage = FrostSigningStage.COMPLETE
+ ),
+ // A key state naming another ceremony: the sibling's, or this parent's
+ // own from an adoption, which is signed in this room too.
+ keyStateSigning(
+ "siblingKeyState",
+ dkgSessionId = "secondCeremony",
+ stage = FrostSigningStage.COMPLETE
+ ),
+ )
+
+ val loaded = awaitLoaded(viewModel) {
+ it.certificateSession == null && it.keyStateSession == null
+ }
+
+ // Both rungs gate on these being null or failed, and a completed signing
+ // is never failed -- so a sibling left in either of them shuts the rung
+ // for good.
+ assertNull(loaded.certificateSession)
+ assertNull(loaded.keyStateSession)
+ }
+
+ private suspend fun load(
+ signingSessions: List,
+ until: (DkgRitualUIState.Loaded) -> Boolean,
+ ): DkgRitualUIState.Loaded {
+ val viewModel = viewModel(MutableStateFlow(signingSessions))
+ viewModel.initiate()
+ return awaitLoaded(viewModel, until)
+ }
+
+ private suspend fun awaitLoaded(
+ viewModel: DkgRitualViewModel,
+ until: (DkgRitualUIState.Loaded) -> Boolean,
+ ): DkgRitualUIState.Loaded = withTimeout(5_000) {
+ viewModel.dkgRitualUIState.first {
+ (it as? DkgRitualUIState.Loaded)?.let(until) == true
+ }
+ } as DkgRitualUIState.Loaded
+
+ /** The screen is always showing the FIRST subgroup's ceremony. */
+ private fun viewModel(
+ signingSessions: MutableStateFlow>
+ ): DkgRitualViewModel {
+ val ceremony = DkgSession(
+ id = "firstCeremony",
+ chatRoomId = parentChatRoomId,
+ coordinatorPublicKey = me,
+ userPublicKey = me,
+ threshold = 2,
+ participantCount = 2,
+ hostPublicKey = me,
+ round1Random = "66".repeat(32),
+ round2AuxRandom = "77".repeat(32),
+ stage = DkgRitualStage.COMPLETE,
+ thresholdPublicKey = firstKey,
+ parentChatRoomId = parentChatRoomId,
+ subject = "Translators",
+ )
+
+ return DkgRitualViewModel(
+ chatRoomId = parentChatRoomId,
+ activeUserPublicKey = me,
+ dkgSessionId = ceremony.id,
+ initialDkgRitualUIState = DkgRitualUIState.Loading,
+ activeIdentityStateFlow = MutableStateFlow(null),
+ chatRepository = object : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY {
+ override suspend fun getChatRoomByIdentifier(id: HexKey): LocalChatRoom? = room()
+ },
+ dkgRepository = object : DkgRepository by DkgRepository.NO_OP_DKG_REPOSITORY {
+ override suspend fun roomDerivedBy(dkgSessionId: String): String? =
+ if (dkgSessionId == ceremony.id) subgroupOf(firstKey) else null
+
+ override fun observeSessionById(sessionId: String): Flow =
+ flowOf(ceremony)
+
+ override fun observeLatestSessionForChatRoom(
+ chatRoomId: String,
+ parentChatRoomId: String?,
+ ): Flow = flowOf(ceremony)
+
+ override fun observeSigningSessions(
+ chatRoomId: String
+ ): Flow> = signingSessions
+ }
+ )
+ }
+
+ /** The room id a ceremony's key derives, which is what a certificate names. */
+ private fun subgroupOf(thresholdPublicKey: HexKey): String =
+ press.mantra.compose.managers.SharedKeyDerivation.marmotGroupId(thresholdPublicKey)
+
+ private fun certificateSigning(
+ id: String,
+ subgroupChatRoomId: String,
+ stage: FrostSigningStage = FrostSigningStage.COLLECTING_NONCES,
+ ) = signing(
+ id = id,
+ stage = stage,
+ event = Event(
+ id = "ee".repeat(32),
+ pubKey = me,
+ createdAt = 0,
+ kind = SubgroupBirthCertificateEvent.KIND,
+ tags = SubgroupBirthCertificateEvent.assembleTags(
+ subgroupChatRoomId = subgroupChatRoomId,
+ parentChatRoomId = parentChatRoomId,
+ thresholdPublicKey = firstKey,
+ adminPublicKeys = listOf(me, peer),
+ name = "Translators",
+ ),
+ content = subgroupChatRoomId,
+ sig = "ff".repeat(64),
+ )
+ )
+
+ private fun keyStateSigning(
+ id: String,
+ dkgSessionId: String,
+ stage: FrostSigningStage = FrostSigningStage.COLLECTING_NONCES,
+ ) = signing(
+ id = id,
+ stage = stage,
+ event = Event(
+ id = "ee".repeat(32),
+ pubKey = me,
+ createdAt = 0,
+ kind = GroupKeyStateEvent.KIND,
+ tags = GroupKeyStateEvent.assembleTags(
+ chatRoomId = subgroupOf(firstKey),
+ dkgSessionId = dkgSessionId,
+ ),
+ content = firstKey,
+ sig = "ff".repeat(64),
+ )
+ )
+
+ private fun signing(
+ id: String,
+ event: Event,
+ stage: FrostSigningStage = FrostSigningStage.COLLECTING_NONCES,
+ ) = LocalFrostSigningSession(
+ session = FrostSigningSession(
+ id = id,
+ stage = stage,
+ chatRoomId = parentChatRoomId,
+ coordinatorPublicKey = me,
+ userPublicKey = me,
+ dkgSessionId = "firstCeremony",
+ threshold = 2,
+ participantCount = 2,
+ signerId = 0,
+ ),
+ unorderedItems = listOf(
+ FrostSigningItem(
+ sessionId = id,
+ itemIndex = 0,
+ unsignedEventJson = event.toJson(),
+ eventId = event.id,
+ nonceRandom = "55".repeat(32),
+ )
+ ),
+ )
+
+ private fun room() = LocalChatRoom(
+ chatRoom = ChatRoom(
+ id = parentChatRoomId,
+ userPublicKey = me,
+ subject = "Ekklesia",
+ description = null,
+ mlsGroupState = "state",
+ ),
+ localParticipants = listOf(me, peer).map { publicKey ->
+ LocalParticipant(
+ participant = Participant(
+ participantPublicKey = publicKey,
+ chatRoomId = parentChatRoomId,
+ relayHint = null,
+ ),
+ profile = null,
+ )
+ },
+ )
+}
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualStartOfferJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualStartOfferJvmTest.kt
new file mode 100644
index 00000000..e811acb0
--- /dev/null
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualStartOfferJvmTest.kt
@@ -0,0 +1,260 @@
+package press.mantra.compose.ui.view.model
+
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+import kotlinx.coroutines.withTimeout
+import press.mantra.compose.database.model.ChatRoom
+import press.mantra.compose.database.model.DkgSession
+import press.mantra.compose.database.model.FrostSigningItem
+import press.mantra.compose.database.model.FrostSigningSession
+import press.mantra.compose.database.model.Participant
+import press.mantra.compose.database.model.intermdiate.LocalChatRoom
+import press.mantra.compose.database.model.intermdiate.LocalFrostSigningSession
+import press.mantra.compose.database.model.intermdiate.LocalParticipant
+import press.mantra.compose.database.model.types.DkgApprovalStep
+import press.mantra.compose.database.model.types.DkgRitualStage
+import press.mantra.compose.repository.ChatRepository
+import press.mantra.compose.repository.DkgRepository
+import press.mantra.compose.ui.view.state.DkgRitualUIState
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
+
+/**
+ * The two ways the key-ceremony screen came to offer to start the ceremony it
+ * was already showing.
+ *
+ * "Start key ceremony" is the whole no-ceremony screen -- the explainer, the
+ * quorum picker and the button -- so anything that leaves the state saying
+ * there is no ceremony puts that offer in front of a member watching one run.
+ * Both of these did, and both are invisible to a test that only asks the view
+ * model what it settles on: the state was right before and right after, and
+ * wrong in between for long enough to read and to tap.
+ *
+ * Neither is about ChillDKG. They are about one `DkgRitualUIState` being
+ * assembled by seven flows at once, which is why they are tested here rather
+ * than against the ritual.
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+class DkgRitualStartOfferJvmTest {
+ private val chatRoomId = "a1b2c3d4".repeat(8)
+ private val me = "11".repeat(32)
+ private val peer = "22".repeat(32)
+
+ @BeforeTest
+ fun setUp() {
+ Dispatchers.setMain(UnconfinedTestDispatcher())
+ }
+
+ @AfterTest
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ /**
+ * The first loaded state already knows the room is running a ceremony.
+ *
+ * The screen used to publish a bare `Loaded` and then wait for the rows,
+ * and a `Loaded` with no session and no ceremony *is* the offer to start
+ * one. On a cold open -- the only time the queries are slow -- that is
+ * exactly when it was shown.
+ */
+ @Test
+ fun `the offer to start is not published while the rows are still being read`() = runBlocking {
+ val session = session(DkgRitualStage.COLLECTING_HOST_KEYS)
+
+ val viewModel = viewModel(
+ object : DkgRepository by DkgRepository.NO_OP_DKG_REPOSITORY {
+ // Slow on purpose: a Room query answers in microseconds and
+ // would hide the window rather than prove it is gone.
+ override fun observeLatestSessionForChatRoom(
+ chatRoomId: String,
+ parentChatRoomId: String?,
+ ): Flow = flow {
+ delay(50)
+ emit(session)
+ }
+ }
+ )
+
+ viewModel.initiate()
+
+ val firstLoaded = withTimeout(5_000) {
+ viewModel.dkgRitualUIState.first { it is DkgRitualUIState.Loaded }
+ } as DkgRitualUIState.Loaded
+
+ assertEquals(session.id, firstLoaded.session?.id, "the first loaded state missed the ceremony")
+ assertFalse(
+ viewModel.canStartRitual(firstLoaded),
+ "offered to start a ceremony on a room already running one"
+ )
+ }
+
+ /**
+ * A write that suspends between reading the state and writing it back does
+ * not carry the stale copy over somebody else's change.
+ *
+ * Every flow folding into this state used to read it, build a `copy` and
+ * assign -- and the session watcher asks the database what this member owes
+ * in the middle of that. Whoever wrote during that pause had their change
+ * thrown away by the assignment that followed. The same pause in the other
+ * direction is what put `session` back to null and offered to start the
+ * ceremony on screen; this is the half of it that can be forced to happen
+ * rather than raced into.
+ *
+ * The pair used here is the session watcher against the room watcher, which
+ * are the two that are genuinely concurrent: both are collecting before
+ * either has written, and neither waits on the other. `roomHasCeremony` is
+ * the field they fight over, and it is the one that decides the offer.
+ */
+ @Test
+ fun `a change written during a suspension survives the write that follows it`() = runBlocking {
+ val live = session(DkgRitualStage.COLLECTING_ROUND_1)
+ val abandoned = session(DkgRitualStage.FAILED)
+
+ val roomSessions = MutableStateFlow(live)
+
+ // Opened once the test has had the room watcher write. The seeding call
+ // runs before anything else is collecting, so it is let through; the
+ // session watcher's call is the one that waits, and `parked` says when
+ // it is in there.
+ val gate = CompletableDeferred()
+ val parked = CompletableDeferred()
+ var calls = 0
+
+ val viewModel = viewModel(
+ object : DkgRepository by DkgRepository.NO_OP_DKG_REPOSITORY {
+ override fun observeLatestSessionForChatRoom(
+ chatRoomId: String,
+ parentChatRoomId: String?,
+ ): Flow = roomSessions
+
+ override fun observeSessionById(sessionId: String): Flow =
+ flowOf(live)
+
+ override suspend fun pendingApproval(session: DkgSession): DkgApprovalStep? {
+ calls += 1
+ if (calls == 1) return null
+
+ parked.complete(Unit)
+ gate.await()
+ return DkgApprovalStep.ROUND_1
+ }
+ }
+ )
+
+ viewModel.initiate()
+
+ awaitLoaded(viewModel) { it.roomHasCeremony }
+
+ // The session watcher is now inside `pendingApproval`, holding -- in the
+ // shape this used to be written in -- a copy of the state as it was
+ // before the next two lines.
+ withTimeout(5_000) { parked.await() }
+ roomSessions.value = abandoned
+ awaitLoaded(viewModel) { !it.roomHasCeremony }
+
+ gate.complete(Unit)
+
+ val settled = awaitLoaded(viewModel) {
+ it.pendingApproval == DkgApprovalStep.ROUND_1
+ }
+
+ assertFalse(
+ settled.roomHasCeremony,
+ "the session watcher wrote back a copy taken before the room watcher's change"
+ )
+ assertEquals(live.id, settled.session?.id)
+ }
+
+ private suspend fun awaitLoaded(
+ viewModel: DkgRitualViewModel,
+ until: (DkgRitualUIState.Loaded) -> Boolean,
+ ): DkgRitualUIState.Loaded = withTimeout(5_000) {
+ viewModel.dkgRitualUIState.first {
+ (it as? DkgRitualUIState.Loaded)?.let(until) == true
+ }
+ } as DkgRitualUIState.Loaded
+
+ private fun viewModel(dkgRepository: DkgRepository) = DkgRitualViewModel(
+ chatRoomId = chatRoomId,
+ activeUserPublicKey = me,
+ initialDkgRitualUIState = DkgRitualUIState.Loading,
+ activeIdentityStateFlow = MutableStateFlow(null),
+ chatRepository = object : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY {
+ override suspend fun getChatRoomByIdentifier(id: HexKey): LocalChatRoom? = room()
+ },
+ dkgRepository = dkgRepository
+ )
+
+ private fun room() = LocalChatRoom(
+ chatRoom = ChatRoom(
+ id = chatRoomId,
+ userPublicKey = me,
+ subject = "Ekklesia",
+ description = null,
+ mlsGroupState = null,
+ ),
+ localParticipants = listOf(me, peer).map { publicKey ->
+ LocalParticipant(
+ participant = Participant(
+ participantPublicKey = publicKey,
+ chatRoomId = chatRoomId,
+ relayHint = null,
+ ),
+ profile = null,
+ )
+ },
+ )
+
+ private fun session(stage: DkgRitualStage) = DkgSession(
+ id = "ceremony",
+ chatRoomId = chatRoomId,
+ coordinatorPublicKey = me,
+ userPublicKey = me,
+ threshold = 2,
+ participantCount = 2,
+ hostPublicKey = me,
+ round1Random = "66".repeat(32),
+ round2AuxRandom = "77".repeat(32),
+ stage = stage,
+ )
+
+ private fun keyStateSigningSession() = LocalFrostSigningSession(
+ session = FrostSigningSession(
+ id = "signing",
+ chatRoomId = chatRoomId,
+ coordinatorPublicKey = me,
+ userPublicKey = me,
+ dkgSessionId = "ceremony",
+ threshold = 2,
+ participantCount = 2,
+ signerId = 0,
+ ),
+ unorderedItems = listOf(
+ FrostSigningItem(
+ sessionId = "signing",
+ itemIndex = 0,
+ unsignedEventJson = """{"id":"${"33".repeat(32)}","pubkey":"$me",""" +
+ """"created_at":0,"kind":30326,"tags":[],"content":"","sig":"${"44".repeat(64)}"}""",
+ eventId = "33".repeat(32),
+ nonceRandom = "55".repeat(32),
+ )
+ ),
+ )
+}
diff --git a/docs/README.md b/docs/README.md
index 15ddd053..5dbb992a 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -9,6 +9,7 @@ silent, or a decision that looked arbitrary and was not.
| [shared-key-ceremony.md](./shared-key-ceremony.md) | ChillDKG over NIP-17: the rounds, the approval gates, the chat transcript, participant ordering |
| [shared-key-derivation.md](./shared-key-derivation.md) | deriving further keys from the group's threshold key with FROST tweaks — why not BIP32, why no chain code, and the one rule that must not be broken |
| [subgroups.md](./subgroups.md) | a group making another group — the four ceremonies, what the parent's signature actually covers, and why the child's key is fresh rather than derived |
+| [subgroups-multiple-parents.md](./subgroups-multiple-parents.md) | adding a parent to a group that already exists — two signings and no ceremony, why the child's consent is the whole gate, and the certificate a group can truthfully sign about itself |
| [frost-batch-signing.md](./frost-batch-signing.md) | signing several events in one ceremony — why one nonce can never cover two messages, and the phased schema, wire and UI work that follows from it |
| [marmot-membership.md](./marmot-membership.md) | how members join an MLS group, and the epoch race that makes a missing member look like a successful invite |
| [member-chronicle.md](./member-chronicle.md) | handing a member added after the work was done the group's signed record — why the events are not on the wire at all, and why the room's id is enough to verify them |
@@ -39,7 +40,11 @@ and the one thing they cannot. The
jvm-target note is unrelated to all of them: it is a build and packaging story.
The subgroups note is a phased plan that has been built; it assumes both
shared-key notes and reads as the ceremony's second half — what a group does
-once it has a key, and what it can say about a group that does not yet.
+once it has a key, and what it can say about a group that does not yet. The
+multiple-parents note is a phased plan that has been built, and reads only after
+it: it takes the one assumption that note never had to state — that a lineage is
+settled at founding — and finds that dropping it costs less than founding a
+subgroup does, because no ceremony is involved.
The Material Design note is a phased plan that has not been built, and is the
only one about what the app looks like rather than what it does; read the
jvm-target note first if you want to know why its adaptive-layout phase exists.
diff --git a/docs/project-feature.md b/docs/project-feature.md
index 5e73a85e..8c06efc8 100644
--- a/docs/project-feature.md
+++ b/docs/project-feature.md
@@ -14,6 +14,7 @@ it belongs to:
| the `summarize` resolver is Phase 4 | the seam is Phase 4, the resolution is Phase 7c | two of its four call sites are composables and cannot await a database, so the names are pre-resolved in `FrostSigningViewModel` |
| three chronicle tests to add | three to add and four to fix | the existing suite compares a seeded room against `CHRONICLABLE_KINDS` itself, and one test asserts every chroniclable kind can also be *rebuilt* -- which projects deliberately cannot |
| around twenty new strings | eighteen, three of them already there | `projects`, `no_projects_exists_in_this_group` and `create_project` came with the stub |
+| schema 21 | schema 22 | `mantra` took 21 for the subgroup parentage table while this branch was open; Phase 0 said to check for exactly this and checked the sibling fork rather than the base |
| the apply arms catch the foreign key violation | they let it out | `ChronicleManager.sweep` retries a page only on a *counted* failure, so catching it loses a membership that crosses a page boundary — and `applySignedEvent`, the reason for catching, already has its own try/catch |
The one thing found while building that the plan did not anticipate: a project
@@ -388,6 +389,13 @@ No code. Settle, in this document:
takes 21; check the Curare clone before claiming it, because two trees that
both call their next migration 21 is a merge nobody can fix afterwards.
+ **This is the one decision the build got wrong, and it checked the wrong
+ tree.** Projects took 21 on their own branch while `mantra` took 21 for the
+ subgroup parentage table (`eba97333`), which is a manual `MIGRATION_20_21`
+ rather than an auto-migration. The collision cost nothing because neither had
+ shipped — projects renumbered to **22** on the way in — but the lesson is that
+ the sibling fork was not the only tree to check. Check the base branch.
+
## Phase 1 — three events and their tags
New, under `press.mantra.compose.nostr.nip30303`:
@@ -559,7 +567,7 @@ rather than four events that share a session.
regression test against anyone folding them back together;
- a `ProjectEvent` case asserting `build` rejects an empty owner set.
-## Phase 2 — three rows, and schema 20 → 21
+## Phase 2 — three rows, and schema 21 → 22
New, under `press.mantra.compose.database.model`:
@@ -651,7 +659,7 @@ and `signedEventsOf` finds it. Adding three more rebuilds would be adding to a
mechanism being retired, to serve rows that cannot exist.
**Schema.** Three tables, nothing existing changes shape, so this is an
-`AutoMigration(from = 20, to = 21)` entry with a comment in the style of the other
+`AutoMigration(from = 21, to = 22)` entry with a comment in the style of the other
nineteen, plus three entities in `@Database`, three `abstract fun`s on
`MantraDatabase`, and three DAOs.
diff --git a/docs/subgroups-multiple-parents.md b/docs/subgroups-multiple-parents.md
new file mode 100644
index 00000000..e3efe414
--- /dev/null
+++ b/docs/subgroups-multiple-parents.md
@@ -0,0 +1,803 @@
+# Adding a parent to a group that already exists
+
+How a group gains a second parent after it was founded.
+
+**Built**, five phases, one commit each. The phases below are kept as written
+because they are the reasoning, and the code reads better against the argument it
+came from than against a summary of itself. Where the implementation chose
+differently the section says so, and it did so eight times worth reading:
+
+| what the note said | what it turned out to be |
+|---|---|
+| the 30329 arm "gains a second branch" | a pure `SubgroupManager.classify` in front of it. The arm cannot tell its two callers apart and the decision wanted testing without a database, which `certifies` makes possible -- it checks a signature against a room id and needs no lookup |
+| nothing about a group certifying itself | **a real gap, found by a test.** A group can sign, truthfully by all six of `certifies`' questions, that it is its own parent. The self-parent guard began 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 list a group as its own subgroup. It predates this work -- `subgroupsOf` would have shown it |
+| drop `ChatRoom.parentChatRoomId` | and the `parentChatRoomId` parameter threaded through four files to reach the write that was its only writer. The note counted the column and missed the thread |
+| `stateFrom` returns a state and a lineage | `LocalGroupKeyState`, a Room read model that is also a plain data class -- so the pure verifier builds one with no database near it, and `@Relation` serves the DAO |
+| the certificate's roster is the founding roster, frozen | true, and silent about adoption, where there is no founding. The adopting group's admins are asked about a group that *exists*, so the roster and name are its **current** ones -- still frozen the moment they are signed, still never read as live |
+| the summary says which parents are being dropped | not done, and it cannot be: a summary is pure and a disavowal is a difference against the row this device holds. It belongs to the screen, which has the room, and there is a comment where somebody will look |
+| `EmptyState` takes a supporting line | it does not. The reason folded into the message, which is the whole content of that absence anyway |
+| `inComparableGroups` moved to the key extensions | in the fork's tree, not this one, where it is still private to `ChatRoomDetailScreen`. A second copy to add three spaces to an id is not worth having |
+
+Two things the note got right that are worth keeping if any of this is rewritten.
+Emitting the parentage tags **pairwise** rather than as two runs, which is the one
+decision here made entirely for builds that do not exist yet -- and which has a
+test asserting the tag order, not just the round trip. And `replace` rewriting a
+lineage rather than merging it, which is what makes a disavowal expressible at all
+and is the reason that method is a transaction.
+
+**Not reachable yet:** `proposeDisavowal` is built and tested and has no button.
+Dropping a parent is a destructive-looking act that wants a confirmation step, and
+it belongs in its own change.
+
+Written against the code as it stands at `e15d31b8`.
+
+Read [subgroups.md](./subgroups.md) first. This extends it and contradicts it in
+one place — the assumption, never stated because it never had to be, that a
+lineage is a single link settled at founding.
+
+Written against the code as it stands at `0be31803` — that is, **after**
+`subgroups.md`'s Phase 10 moved the child's ceremony into the parent's Marmot room.
+That move removed two of the costs an earlier draft of this note claimed adoption
+would avoid and reshaped a third, so the case is made here on what is left. It also
+makes founding with two parents *harder* — see the
+[rejected alternatives](#considered-and-rejected).
+
+## The short answer
+
+**Adoption is two FROST sessions and no ceremony.**
+
+```
+ 1. birth certificate, in the new parent -> P2's quorum signs C
+ 2. key state, in C itself -> C's quorum re-announces, carrying
+ every certificate it accepts
+```
+
+That is the whole flow. Compare it with founding, which is four steps:
+
+| founding a subgroup | adopting an existing group |
+|---|---|
+| 1. ChillDKG among the child's admins | — the key already exists |
+| 2. birth certificate, in the parent | **the same step, unchanged** |
+| 3. key state, in the parent's room where the ceremony ran | **the same event, in the child's own Marmot room** |
+| 4. create the Marmot room | — the room already exists |
+
+Losing step 1 is the point. `subgroups.md` names it as the single biggest
+practical cost of a subgroup — *"a ChillDKG cannot finish without all `n`… a
+member who never approves stalls the subgroup at step 1 indefinitely"* — and it
+is also the last thing standing between a lineage and being an ordinary edit.
+
+Phase 10 has already paid down what it used to drag in: the NIP-17 sibling room and
+the member-derived id are gone, and the collision guard is now
+`DkgSessionDao.getLatestSubgroupSessionFor` over `(room, parent, participants)` rather
+than an address that could only be occupied once. So this note claims none of those as
+savings — only the ceremony and the room creation, which is enough.
+
+**The whole of `DkgSession`, `ChillDkgRitualManager`, `MarmotGroupCreation`,
+`SelectSubgroupAdminsScreen` and the ritual screen's rung is untouched by this
+design** — and Phase 10 makes that a stronger statement than it was, because those
+files have just absorbed a large change that adoption does not have to follow.
+
+Three things in the existing code make step 2 work as it stands:
+
+- `GroupKeyStateManager.propose` already permits a room to propose its own key
+ state: `check(chatRoomId == signingRoomId || key.chatRoomId == signingRoomId)`,
+ and for an existing child the first branch holds. Nothing forbids
+ re-announcement today; it is simply never done.
+- `FrostSigningManager.signingPath` resolves correctly for it. Its first candidate
+ is `GroupKeyStateManager.keyStateFor(chatRoomId)?.pathIndices()`, which for the
+ child is `m/9420/0/0`, and `marmotGroupId(K, path) == chatRoomId` self-checks.
+ The one un-self-checkable case is **not** reached. Phase 10 widened it from
+ `mlsGroupState == null` to `isCeremonyRoom && (mlsGroupState == null ||
+ isSubgroupCeremony)`, so it now admits the parent's room as well as a NIP-17 one —
+ and adoption enters neither arm, because the child's own room is neither the
+ ceremony's room nor its claimed parent.
+- `GroupKeyStateDao.replace` keeps the newest state per room by `announcedAt`, and
+ `announcedAt` comes off the signed event. So a re-announcement replaces the old
+ lineage cleanly, and an out-of-order arrival is dropped rather than applied.
+
+## A parent link is two signatures, one from each side
+
+Worth stating plainly, because the founding flow bundles them and so hides it.
+
+The certificate is the **parent's** consent: P's quorum saying "C is our child".
+The parentage tags on the key state are the **child's** consent: C's quorum saying
+"we accept P". Neither alone is a link. A parent cannot adopt a group that has not
+agreed, and a group cannot claim a parent that has not signed — which is exactly
+why `GroupKeyStateManager.parentageOf` drops a state whose certificate does not
+check out, and why `SubgroupManager.proposeBirthCertificate` runs in the parent's
+room rather than the child's.
+
+At founding the two are bundled because the key state has to be signed anyway: it
+is the founding fact, and the parentage rides along. For an existing group there
+is no founding fact left to settle, so **step 2 is a key state signed purely as a
+carrier for the child's consent.** That is the one slightly odd thing about this
+design and it is worth doing anyway — see [why not a separate adoption
+kind](#considered-and-rejected).
+
+Once that is seen, the whole thing collapses to one sentence:
+
+> A group's lineage is a set of (parent, certificate) pairs carried on its key
+> state. Adding a parent is: that parent certifies, then the child re-announces
+> its key state with the pair added. Founding a subgroup is the case where the
+> set has one member and the key state was being signed anyway.
+
+## The invariant, and how it differs from founding-time multi-parent
+
+If two parents certified a child *at founding*, both certificates would be signed
+at the same moment and could name each other — so a reader could demand that every
+certificate agree on the whole parent set, and a coordinator could not staple two
+unrelated certificates into a joint child.
+
+**That check is not available here, and cannot be made available.** P1 certified C
+before P2 existed as a possibility. An earlier signature cannot name a later
+co-parent, and asking P1 to re-certify every time a sibling appears makes every
+adoption cost a quorum in every existing parent.
+
+So the invariant has to be weaker, and the honest version is:
+
+> **The child's key state is the sole authority on the lineage set. Each
+> certificate is checked individually — this parent signed for this child — and
+> the set is whatever the child's quorum most recently declared.**
+
+Which means a parent gets a veto on being *added* and no veto on *who else is in
+the set*. That is not a compromise; it is the correct answer:
+
+- Nothing P1 signed becomes false. P1 said "C is our child". C being P2's child
+ as well does not contradict it.
+- Exclusivity was never expressible and could not be enforced anyway. P2 can
+ certify C whether P1 likes it or not — a certificate is a unilateral statement
+ about a public room id. What P2 cannot do is make C *accept* it.
+- **The child's consent is the whole gate**, and it should be, because the child
+ is the one that acquires an obligation.
+
+`parentageOf` therefore grows from four checks to six, and the two new ones are
+small:
+
+1. **All or none**, per pair. (Today's rule.)
+2. Each certificate parses. (Today's.)
+3. Each `certifies(certificate, child, itsParent)`. **`certifies` does not
+ change** — it is six questions about one signer and one child and it stays
+ that, so `SubgroupBirthCertificateEventTest`'s existing seven cases keep
+ passing untouched.
+4. Each certificate's `subgroup_key` is the state's own threshold key. (Today's.)
+5. **No duplicate parents.** A set with a repeat in it is not a set.
+6. Any failure drops the whole state. (Today's.)
+
+There is no `co_parent` tag and no cross-certificate agreement check. Both were
+needed only by the founding-time design, and both are gone.
+
+### The footgun this creates
+
+A key state is replaceable and the newest wins **wholesale**. So re-announcing to
+add P2 must re-emit P1's pair as well, or P1 is silently dropped.
+
+That is a bad API to leave open. `propose` should take the *resulting* set and
+read the current one off the row, so that "add P2" is expressed as an addition
+rather than as a full replacement a caller has to remember to assemble. The same
+call then expresses removal, which is the next section.
+
+## What this also unlocks
+
+Two things fall out of the same mechanism, and neither needs extra work.
+
+**Any existing robust group can become a subgroup.** Nothing in the two steps
+requires the child to already have a parent. A top-level group with a key state
+gets adopted exactly the same way, with the set going from empty to one. That is
+arguably worth more than multi-parent itself, and it is the same code path.
+
+**A child can drop a parent.** Re-announce with the pair removed. This partly
+answers `subgroups.md`'s *"Removing a subgroup is not a thing"* — and only partly,
+so the limit is worth being precise about:
+
+- The certificate is forever. Nothing revokes a signature and nothing should
+ pretend to.
+- But a link is two signatures, and the child's half is replaceable. So the child
+ can stop claiming a parent, and every device holding its key state will stop
+ believing the link.
+- The parent's own list does **not** follow, because `SubgroupManager.subgroupsOf`
+ reads the parent's certificates, not the child's key state. P1 keeps listing C.
+- That is fixable in the display without any new protocol: `Subgroup` already
+ carries a `keyState` field, populated where the device holds the child's state.
+ A parent device that also holds C's key state can see that C no longer names it
+ and say so. A parent device that does not hold it cannot, and must not guess.
+
+## The wire
+
+### The key state carries pairs, in matched order
+
+No new tag names. `parent_group` and `birth_certificate` repeat:
+
+```
+["parent_group", ]
+["birth_certificate", ]
+["parent_group", ]
+["birth_certificate", ]
+```
+
+`SubgroupParentTag.parse` and `SubgroupBirthCertificateTag.parse` already work
+per-tag; the readers change from `firstNotNullOfOrNull` to `mapNotNull`, and
+`assembleTags` takes a list instead of a nullable single. That is the entire wire
+change.
+
+**The order is load-bearing, and only for old builds.**
+`GroupKeyStateEvent.parseParentChatRoomId` and `parseBirthCertificate` take the
+*first* of each, so a build predating this pairs `parent_group[0]` with
+`birth_certificate[0]` and runs `certifies` on them:
+
+- emitted **pairwise** — P1, P1's cert, P2, P2's cert — it pairs P1 with P1's
+ certificate, it verifies, and the device files a true but partial lineage.
+ Correct, just less.
+- emitted **grouped** — both parents, then both certificates — it is coherent only
+ while both lists happen to be in the same order, and silently wrong the moment
+ they are not. Then `certifies` fails and the old build **drops the whole state**,
+ losing the row that says which ceremony's share signs for that room. Recoverable
+ through `completedKey`'s rederivation fallback, and only for default-path rooms.
+
+So: emit pairwise, sorted by parent id so every device produces the same bytes.
+Free, and the difference between graceful degradation and an old device quietly
+losing its key state.
+
+### The certificate does not change at all
+
+Kind 30329 keeps its tags, its `certifies`, and its meaning. A certificate signed
+for a founding subgroup and one signed for an adoption are the same event — which
+is right, because they say the same thing.
+
+## The schema
+
+Much smaller than the founding-time version, because `DkgSession` is not involved.
+
+**`GroupKeyState`: one-to-many.** The two columns `parentChatRoomId` and
+`birthCertificateJson` become a table:
+
+```kotlin
+@Entity(primaryKeys = ["chatRoomId", "parentChatRoomId"], foreignKeys = [
+ ForeignKey(entity = GroupKeyState::class, parentColumns = ["chatRoomId"],
+ childColumns = ["chatRoomId"], onDelete = ForeignKey.CASCADE)
+])
+data class GroupKeyStateParent(
+ val chatRoomId: String,
+ val parentChatRoomId: HexKey,
+ val birthCertificateJson: String,
+)
+```
+
+The composite primary key *is* check (5) — one certificate per (child, parent),
+enforced by the store rather than by a reader remembering. The cascade is onto
+`GroupKeyState`, never onto `ChatRoom`, so `subgroups.md`'s argument against a
+self-referential cascade stands untouched: the parent side stays a plain column
+that routinely names a room this device does not have.
+
+The cost is that filing a state stops being one write. `GroupKeyStateDao.replace`
+has newest-wins logic and would have to become a `@Transaction` that rewrites the
+parentage rows with the state, or a dropped parent survives the re-announcement
+that dropped it — which is exactly the operation this design adds.
+
+**`DkgSession`: untouched.** Adoption opens no ceremony, so it needs no session, no
+discriminator and no migration there. Phase 10 has just added two columns to that
+table — `participantPublicKeys`, a sorted comma-joined set matched whole by
+`getLatestSubgroupSessionFor`, and `subject` — which is worth knowing for two
+reasons: it is the precedent for the canonical-string shape, and it is the change
+adoption is glad not to be entangled with.
+
+**`ChatRoom.parentChatRoomId`: drop it.** It is write-only in production (see
+[found while reading](#found-while-reading)), and leaving it holding one of
+several parents makes it a cache that is wrong rather than merely unused.
+
+Schema **20 → 21**, and the number moves under this note faster than the design does:
+19 was Phase 10's, for `DkgSession.participantPublicKeys` and `subject`, and 20 was the
+several-profiles pull's, putting an owning identity on the queues. So take the version
+from `MantraDatabase` when this is built rather than from here. One new table, one
+dropped column, and the existing rows carried across as a single parentage row each.
+Not an `AutoMigration`; schema 9→10 already has the shape to copy.
+
+### The zero-migration alternative, and why not
+
+The parentage columns are already a *cache*: `stateFrom` derives them from the
+signed 30326 event, and `signedStateFor` can re-read the newest such event for any
+room at any time. So the lineage could be a derived read — parse the signed event,
+run the checks, return the set — with no schema change whatsoever.
+
+Worth knowing it is available, and worth not taking. `signingPath` reads the
+`GroupKeyState` row on **every signature**, so the row stays either way; a design
+where the path comes off the row and the lineage comes off the event is two
+sources for one statement, which is the thing `subgroups.md` spends its appendix
+refusing. Cache the whole state or cache none of it.
+
+## Who can actually do this
+
+The one requirement that does not soften, and it should be stated before the UI is
+designed around it.
+
+**Step 1** — proposing the certificate in P2 — needs a P2 admin holding a share of
+P2's key, *who also knows the child's threshold key*. The certificate's tags carry
+`subgroup_key`, and without it `certifies` fails at question 4. The child's
+threshold key lives on the child's `GroupKeyState`, which only the child's members
+hold. So the proposer is in both groups.
+
+**Step 2** — proposing the key state in C — needs a member of C holding a share of
+C's key, *who has P2's certificate*. Certificates are filed as `GroupSignedEvent`
+rows by `recordSignedEvents`, keyed on the session's room, so only P2's members
+have one. So this proposer is also in both groups.
+
+They need not be the same person, and their capabilities differ — step 1 needs
+admin rights in P2, step 2 needs a share of C's key. But **adoption requires at
+least one person in both groups**, and there is no way around it: the certificate
+has to cross a boundary that nothing in the app crosses.
+
+### One human, two profiles, and an unscoped read
+
+Since several profiles landed, "one person in both groups" has a second reading, and
+it is the one that will happen first: the same human as a P2 admin under one profile
+and a holder of C's share under another.
+
+That satisfies both steps and needs no single key in both groups, which is a real
+loosening. The mechanism is the part to be careful about. `ChatRoom` is keyed by **the
+group id alone** — `679d5249`'s message says so, and the MLS state, the DKG sessions
+and the FROST shares all hang off the room — so the two rooms are two rows with
+different ids and different `userPublicKey` holders in **one** database. P2's
+certificate is a `GroupSignedEvent` hanging off P2's room, and
+`groupSignedEventDao().getById(id)` is not scoped by identity, so the other profile's
+code can read it.
+
+So it works, and it works by reading across a boundary the app has already had to
+defend once: `679d5249` exists because the Welcome branch could not tell a redelivered
+Welcome from one for a room another profile holds, and told nobody.
+
+**And the key check this design leans on does not draw that boundary either.**
+`DkgSession` carries a `userPublicKey`, but `getKeyHoldingSessions()` is
+`WHERE thresholdPublicKey IS NOT NULL AND secretShare IS NOT NULL` with no identity in
+it, and `getLatestOwnSessionForChatRoom` is scoped only by room and
+`parentChatRoomId IS NULL`. So `FrostSigningManager.canSign` answers a **device-wide**
+question — this device holds a share — where every sentence in this note that says
+"this device holds no share of P2's key" means the profile in the chair.
+
+That is pre-existing and device-local: both profiles' shares are already on one disk,
+owned by one person, so nothing crosses a user boundary. What it costs is profile
+isolation, which is the point of having profiles at all — and it is why **a step-2
+proposer must take the certificate from a source scoped to the profile that is
+acting**, rather than from whatever the database happens to hold. Otherwise adoption
+is a feature that silently works better when you own both sides, which is the shape of
+a leak rather than of a convenience. Worth its own look, not this note's to fix.
+
+### Still weaker than founding-time multi-parent
+
+Where one coordinator had to administer both parents *and* run a ceremony. And it is a
+realistic requirement — a group being adopted by another group almost certainly has
+someone
+in both.
+
+What would remove it is a cross-group request: a way for C to publish "we would
+like P2 to adopt us, here is our key" and for P2's admins to act on it. That is a
+real feature with its own consent questions, and it should not be smuggled in as
+a helper.
+
+### The certificate rides on the key state, and needs no storage of its own
+
+The obvious-looking alternative — file P2's certificate on the child's devices
+when it arrives — is closed, and the closure is load-bearing:
+`GroupSignedEvent` declares a foreign key onto `ChatRoom`, so a child device with
+no P2 room row **cannot** store a P2-signed event at all.
+
+It does not need to. `GroupKeyStateManager.propose` takes the certificate as an
+`Event`, not as a row, and once the child's quorum signs the re-announced state
+the certificate travels inside it — verified standalone by `stateFrom` on every
+child device, exactly as at founding. The proposer needs the event; nobody else
+does.
+
+### The one channel that must not be used, and it is already built
+
+There is now a path that would carry a certificate out of the encryption, and it is
+dormant rather than absent. `BroadcastGroupSignedEventViewModel` sends one
+group-signed event to relays a member picks, and its own comment states the property
+that makes it dangerous here: *"the signature is the group's, so any member holding
+the event can send it and no share of the key is needed."* It is **not** restricted by
+kind — "the seed is the same for every event of every kind" — and nothing opens it
+today only because the rows that carried the button went with the curated lists.
+`BroadcastGroupSignedEventRoute` is still registered and `BroadcastButton` still
+built, so, in that file's words, wiring a button in is all it takes.
+
+A certificate is the worst event in the app to send. It carries the parent-child
+lineage, the child's threshold key, and the founding admin roster in its `p` tags —
+a membership list — and `subgroups.md` places kind 30329 "in the private 303xx range
+with everything else that never leaves an encryption".
+`SubgroupBirthCertificateEvent`'s own comment leans on that: because it only ever
+exists inside an encryption a relay cannot open, "the addressable semantics of the
+3xxxx range never fire". Published, they fire — it is `d`-tagged on the child's room
+id, so relays would treat certificates as replacements of one another, which is a
+thing this design settles locally and on purpose.
+
+**So this design carries a negative rule: the subgroups list and the parent cards must
+never grow a broadcast button**, and a certificate must not be reachable from whatever
+screen a future "broadcast this" sweep walks. It is also why relays are not the answer
+to the overlap requirement above, tempting as it looks: a P2 member could publish the
+certificate and a C member could fetch and verify it — `certifies` needs no lookup —
+but the price is publishing the lineage and the roster to anyone, to save one person
+being in two groups.
+
+## Choosing the parent
+
+Worth its own section, because "pick a group" is not what the picker can offer and
+designing the screen around the wrong list would be the easy mistake.
+
+### The list is the selector's own rooms, and cannot be anything else
+
+A certificate is proposed **inside** the adopting group, by
+`FrostSigningManager.proposeSigning` in that room. So the candidate list is not "the
+groups that exist" or "the groups C's admins can name" — it is the rooms **the active
+profile** can act in. Not the device's: `getChatRoomListByUserPublicKey` is keyed by
+identity, and since several profiles landed a device can hold rooms it must not offer
+here, because the key that would have to sign is not the one in the chair.
+
+```
+getChatRoomListByUserPublicKey(user)
+ filter { it is a Marmot room whose MarmotGroupData reads }
+ filter { adminPubkeys.contains(userPublicKey) }
+ filter { FrostSigningManager.canSign(database, it.id) }
+ filter { it.id != C }
+ filter { it.id !in C's current parents }
+ filter { it.id is not a descendant of C }
+```
+
+The first three filters are `SubgroupManager.refuseSubgroup`'s existing checks with
+the target moved from "the parent of a new subgroup" to "the adopting group", and the
+admin one reads `MarmotGroupData.adminPubkeys` rather than `Participant.adminAt`, for
+the reason that function already gives: the flag is written only by
+`processGroupMembershipChanges` on an arriving commit, so it is null for everybody on
+a room this device created.
+
+**A group cannot be offered as a parent by somebody who is not in it.** That is the
+overlap requirement from [who can actually do this](#who-can-actually-do-this) showing
+up as a list length. If nobody in C administers P2, P2 simply does not appear, and the
+remedy is a person rather than a button.
+
+### Cost, and where it goes
+
+`toMlsGroup()` deserialises the whole group state out of hex to reach `adminPubkeys`,
+once per candidate room. `canSign` is cheap for exactly the rooms that pass —
+`completedKey`'s first branch is a key-state lookup and a session lookup by id, and
+its expensive fallback (`getKeyHoldingSessions` with a `marmotGroupId` derivation per
+session) only fires for rooms with no key state, which fail the filter anyway. Its last
+fallback is now `getLatestOwnSessionForChatRoom` — the same query scoped to
+`parentChatRoomId IS NULL`, so that a parent hosting its child's ceremony cannot
+resolve the child's key as its own.
+
+So the filter is one MLS decode per room and two indexed reads. Fine at twenty rooms
+and worth building the way `SelectChatRoomMembersViewModel.scheduleKeyPackageEventSynchronization`
+already builds its prefetch: off the main thread, filling in as it resolves, rather
+than blocking the screen on the whole list.
+
+### The selection is an offer, not a decision
+
+The ordering is forced — the key state carries the certificate, so the certificate is
+signed first — and that has a consequence worth stating in the UI's own words:
+
+> **P2's quorum commits first. C's quorum decides second.**
+
+Whoever picks P2 is not choosing on C's behalf. They are asking P2 to make an offer,
+and C's admins answer it at step 2 by signing the re-announced key state or not. The
+real decision point for C is the key-state proposal, which is why
+[the `ProposedEvent` gap](#consent-on-both-sides) is the most important small change
+in this design.
+
+It also means P2's quorum can be spent on an offer that is declined. That is not new —
+founding has the same ordering, and `proposeBirthCertificate` runs before the child's
+quorum has signed anything either — but at founding the child's admins have at least
+*seen* the intent, because `DkgRitualEvents`' `parent_group` tag rides on the ceremony
+proposal and the ritual screen says which group it is for. Adoption has no ceremony
+and so no such hint.
+
+### So C's admins need telling, and the arm to tell them already exists
+
+Without one, the first thing an admin of C hears about the whole business is a
+proposal asking for their signature.
+
+`ChatMessage.applyInnerEvent`'s 30329 arm should grow a second branch. Today it calls
+`SubgroupManager.record`, which asks `certifies(event, subgroup, chatRoomId)` — the
+certificate's parent must be the room it arrived in — and drops anything else. A
+certificate that instead names *this* room as its subject, and verifies against the
+parent it names, is the arriving offer:
+
+| the certificate | the line |
+|---|---|
+| signed by this room, about another | "The group made #Translators a subgroup…" (today's) |
+| about this room, signed by another | "#Ekklesia has offered to adopt this group" |
+
+Both branches are fully verified and neither needs a lookup: `certifies` checks the
+signature against a room id, and a room id is a public key. Nothing is stored — the
+`GroupSignedEvent` foreign key onto `ChatRoom` forbids it, and nothing needs it,
+because the certificate reaches C's devices for real inside the key state at step 2.
+The line is notice, not evidence, and should read as an offer rather than as a fact
+about C's lineage.
+
+Delivering the certificate into C is then one act by the member who is in both, and it
+is the same act that gives the step-2 proposer the event they need.
+
+### Which screen it starts from
+
+Two defensible entry points, and they produce an identical certificate:
+
+- **C's detail screen**, beside the parent card: *"add a parent group"*. The child is
+ the one acquiring the relationship, its quorum is the one that ultimately decides,
+ and it mirrors the parent screen's existing *"add subgroup"*.
+- **P2's detail screen**, beside *"add subgroup"*: *"adopt an existing group"*. This is
+ where the proposal actually lands, and where P2's admins would look for what their
+ group has taken on.
+
+C's screen is the better default, for the mirroring and because the intent usually
+originates with the group looking for a parent. P2's is worth adding later; the guards
+are the same set read in the other direction, and the candidate list becomes "Marmot
+rooms I hold a key state for", since that is where `subgroup_key` comes from.
+
+## Consent, on both sides
+
+The parent's side already works. A P2 admin sees `ProposedEvent`'s 30329 summary —
+the name and the admin count — and their device checks
+`marmotGroupId(subgroup_key, frost_path) == content` before they agree. Unchanged.
+
+**The child's side does not, and this is the gap that has to close.**
+`ProposedEvent`'s 30326 arm reads:
+
+```
+label: "This group's shared key"
+detail: "m/9420/0/0 · a1b2c3d4e5f6"
+```
+
+A child admin approving an adoption would be shown the path and a ceremony id, and
+nothing whatever about the fact that they are accepting a parent. The proposal is
+their only chance to check — after it, a quorum has spoken — so the arm has to
+read the parentage off the tags it is already holding and say which groups are
+being accepted, and which are being dropped relative to the current row.
+
+That is the single most important small change in this design. Everything else is
+plumbing; this is the one place where a member is asked a question and is
+currently not being told what it is.
+
+## Guards
+
+Two new refusal sets, both in the shape `SubgroupManager.refuseSubgroup`
+already has: checked before the button is offered, and again in the manager.
+
+**Before proposing a certificate in P2** — the first three are its existing checks,
+retargeted from "the parent of a new subgroup" to "the adopting group":
+
+| refused | because |
+|---|---|
+| P2 is not a Marmot room with readable group data | no admin list to consult; the same reason the subgroup button is hidden on a NIP-17 room |
+| this device is not an admin of P2 | a non-admin proposing the group's signature is a proposal the admins must decline by hand |
+| this device holds no share of P2's key | `proposeSigningBatch` throws, and throwing at a button is not a UI |
+| this device does not hold C's key state | there is no `subgroup_key` to certify, and a certificate without one fails `certifies` question 4 on every device including this one |
+| C **is** P2 | a group is not its own parent |
+| P2 is already among C's parents | that is a re-certification, not an adoption. `proposeBirthCertificate`'s `check(certificateFor(...) == null)` already refuses it; it should say so in those words |
+| P2 is a descendant of C, as far as this device can see | a cycle. Bounded walk, advisory only — see below |
+
+**Before proposing the key state in C:**
+
+| refused | because |
+|---|---|
+| this device holds no share of C's key | same reason |
+| any certificate in the proposed set fails `certifies` | already checked by `propose`; now per pair. Proposing a state this device would drop spends a quorum's attention on nothing |
+| the set has a duplicate parent | check (5), at the door rather than only on read |
+| the set is unchanged from the current row | nothing to agree |
+
+**"This device" in both tables means the profile in the chair**, and `canSign` does
+not currently tell the two apart — see
+[one human, two profiles](#one-human-two-profiles-and-an-unscoped-read). Until it
+does, these guards are honest about capability and silent about persona.
+
+## New failure modes
+
+**A lineage is a DAG now, so a walk can loop.** With one parent it is a tree and
+`parentOf` returns a single id, so nothing was tempted to recurse. A cycle is
+constructible — C's quorum certifies P1 using P1's public threshold key, and P1's
+quorum re-announces its own key state accepting it, which this design is precisely
+what makes possible. It takes both quorums cooperating in something pointless, but
+any ancestor walk must be depth-bounded and visited-set guarded regardless, and
+the guard above is only as good as what this device can see. `subgroups.md` already
+notes depth 2 is untested; this makes it worth testing rather than merely noting.
+
+**A half-adopted state exists between the two steps** — P2 has certified and C has
+not accepted — and every reader has to render it as an ordinary intermediate rather
+than as an error. Note it is *invisible* from C's side unless the certificate has
+been brought across, which means the child's screen cannot show "P2 has certified
+you" for a member who is not in P2.
+
+**A re-announcement that forgets an existing parent drops it silently.** Addressed
+by the API shape above, and worth a test that names it.
+
+## The UI
+
+Two rungs on the child's `ChatRoomDetailScreen`, beside the parent card. No new
+screen, and no new approval UI at all — both proposals are ordinary
+`FrostSigningEvents.PROPOSAL`s that `ProposalListScreen` and `FrostSigningScreen`
+already show and approve.
+
+**"Add a parent group"** opens the picker described under [choosing the
+parent](#choosing-the-parent) and proposes the certificate in whichever group is
+chosen. The picker is a pushed screen, so it takes an `onNavigateBack` and puts
+`NavigateBackButton` in its app bar's leading slot — `CLAUDE.md` gained that rule
+while this note sat unbuilt, and the desktop target has neither a system back nor an
+edge swipe to fall back on. Offered only where step 1's guards pass, hidden rather than disabled, in the
+same way the shared-key entry is hidden on an MLS room — and empty for a member who
+administers none of C's candidate parents, which is a fact about them rather than a
+failure.
+
+**"Accept #P2 as a parent"** appears once a certificate this device can see has
+been signed, and proposes the re-announced key state. Offered to any member of C
+holding a share — not only to whoever started, for the same reason `subgroups.md`
+gives for the ritual screen's rung. Its wording is the decision, not the mechanism:
+C is accepting an offer, and the transcript line from the arriving certificate is
+what it is accepting.
+
+**The parent card becomes up to N cards.** It reads
+`ChatRoomDetailUIState.parentChatRoomId` ← `chatRepository.parentOf` ←
+`GroupKeyState`, so it is a straight plural, and it keeps the rule it has: absent
+rather than guessed where the state is not held.
+
+**`SubgroupManager.Subgroup` gains a "still claims us" signal**, off the `keyState`
+field it already carries. Only where the device holds the child's state; silent
+otherwise.
+
+## Tests
+
+- `SubgroupBirthCertificateEventTest` — **unchanged, and that is the assertion**.
+ `certifies` does not change, so all seven cases including the one whose job is
+ to fail if a roster check is added must keep passing with no edits.
+- `GroupKeyStateTest` (commonTest, pure) — two parents both good; two parents with
+ one certificate missing → drop; the same parent twice → drop; a single-parent
+ state in today's shape still accepted, which is the compatibility case; and an
+ empty set on a state carrying no parentage tags, which is every group made
+ before this.
+- A pairing-order test, because the reason for pairwise emission is a build that
+ does not exist yet: assemble a two-parent state, read it with the *old*
+ first-tag-wins parsers, and assert the pair that comes out is coherent.
+- `SignedGroupKeyStateTest` (jvmTest) — the one that proves the flow. A real child
+ key, a real P1 key and state, a real P2 key, a real certificate by P2 over the
+ child, a real **re-announced** state carrying both, and the assertion that
+ `stateFrom` accepts it and rejects each single-field mutation.
+- `SubgroupDaoJvmTest` — the new schema round-trips the table, a newer state replaces
+ the parentage rows of an older one, and a re-announcement that drops a parent
+ leaves no row behind.
+- `SubgroupManagerJvmTest` — the two guard tables, and the candidate filter: a room
+ this device is in but does not administer is not offered as a parent, and neither
+ is one it administers but holds no share of.
+- `ChatMessageJvmTest` — the 30329 arm's second branch: a certificate about this room,
+ signed by another, writes the offer line; one signed by neither this room nor the
+ parent it names writes nothing.
+
+## Rollout
+
+Additive apart from the migration.
+
+`assembleTags` emits pairs only where a lineage is passed, so every state already
+signed verifies exactly as it did. The plural parsers read a single pair as a set
+of one. The new checks fire only on a state carrying more than one pair.
+`certifies` does not change. **The founding flow is not touched at all** — a
+subgroup is still founded with one parent, and multi-parent is reached by founding
+normally and then adopting.
+
+An older build meeting a two-parent state files a true, partial lineage — one
+parent, verified — as long as the tags are pairwise. An older build meeting a
+re-announced *single*-parent state sees an ordinary key state and replaces its row,
+which is correct.
+
+The migration is the only non-additive part, and the risk in it is the write path
+rather than the read: `replace` becoming transactional is where a bug would put a
+stale parent on a state that no longer claims it.
+
+Phase 1 (the wire) and phase 2 (the schema) can ship dark. The rest is inert
+without the two rungs, so the flag, if one is wanted, is again the button.
+
+## Considered and rejected
+
+**Founding a subgroup with two parents at once.** The version this question
+replaced. An earlier draft rejected it on costs Phase 10 has since removed — the
+member-derived ceremony room and the parent set as a `DkgSession` discriminator — so
+the argument has to be made again, and it comes out the same way for a better reason.
+
+**Phase 10 makes founding with two parents harder, not easier.** The ceremony now runs
+in *the parent's room*, and a child with two parents has two candidate rooms and no
+principled way to choose: `DkgSession.chatRoomId` is one column, the ceremony's
+messages ride that room's MLS transport, and the members of the other parent — who may
+be admins of the child — are not in it to receive them. Running it in both means two
+ceremonies and two keys, which is two children. Running it in one means the other
+parent's admins take part in a ceremony held somewhere they cannot see, which is not a
+thing MLS will do.
+
+Adoption sidesteps the question entirely by not holding a ceremony, and what remains
+against founding-with-two is unchanged: it still needs a co-parent tag so certificates
+can agree on the set, a coordinator who administers both parents, and two quorums
+answering before the child room exists at all — and it still cannot adopt a group that
+already exists, which is the case people actually have. Founding with two parents stays
+reachable: found with one, adopt the second, and the room is a few minutes old instead
+of a few seconds.
+
+**A separate adoption kind, leaving the key state alone.** Cleaner-sounding: a new
+event by the child's quorum saying "we accept P2", so the founding fact is never
+rewritten. It splits the lineage across a founding key state plus N adoption
+events, and then a device holding the state but missing adoption #2 has an
+incomplete lineage **and no way to tell that it is incomplete**. One replaceable
+statement is always whole or absent. That property is worth more than the
+tidiness of not re-signing.
+
+**Filing foreign certificates on the child's devices.** Closed by
+`GroupSignedEvent`'s foreign key onto `ChatRoom`, and unnecessary: the certificate
+rides inside the key state.
+
+**An `exclusive` flag on the certificate**, meaning "we certify C on condition it
+has no other parent", enforced by dropping any state whose set has more than one
+member when any certificate claims it. Cheap and checkable, and it hands a group
+that has gone quiet a permanent veto over a group that has not. If exclusivity is
+ever wanted it should be a social arrangement, not a tag.
+
+**Requiring every certificate to agree on the parent set.** Impossible here, and
+the reason is the interesting part: P1 signed before P2 was a possibility. See
+[the invariant](#the-invariant-and-how-it-differs-from-founding-time-multi-parent).
+
+## Found while reading
+
+Six things, none caused by this, all worth knowing.
+
+**A built, unreachable path would put any group-signed event on public relays.**
+`BroadcastGroupSignedEventViewModel`, not restricted by kind, reachable by wiring in
+one button. Argued above as the
+[channel that must not be used](#the-one-channel-that-must-not-be-used-and-it-is-already-built);
+listed again here because it is a fact about the tree rather than about this design,
+and the next person to wire a broadcast button will not be reading this note.
+
+**`ChatRoom` is keyed by the group id alone, not by (group, profile).** So two
+profiles on one device cannot both hold one group — the second one's Welcome is
+refused by `679d5249` — while rows belonging to different profiles sit in one database
+with nothing scoping a read by id across them.
+
+**`FrostSigningManager.canSign` is device-wide, not profile-wide.**
+`getKeyHoldingSessions()` carries no identity predicate although `DkgSession` has the
+column, so the active profile can resolve a key whose share another profile
+generated. Device-local and pre-existing, and a profile-isolation gap rather than a
+cross-user one, but it is load-bearing for every "can this act" check in the app and
+not only for the ones here.
+
+**`ChatRoom.parentChatRoomId` is write-only in production.**
+`MarmotGroupCreation` sets it; its only reader is
+`ChatRoomDao.observeByParentChatRoomId`, whose only callers are tests. So are
+`GroupKeyStateDao.getByParentChatRoomId`, its `observe` sibling, and
+`DkgSessionDao.getByParentChatRoomId`. The live reads both go elsewhere —
+`parentOf` off `GroupKeyState`, `subgroupsOf` off the certificates.
+
+**`ProposedEvent`'s 30326 arm says nothing about parentage.** It shows the path
+and the ceremony id. Today that is harmless, because a key state carrying a
+parentage is only ever proposed at founding, where the proposer is the
+coordinator who chose it. It stops being harmless the moment a key state can be
+re-announced.
+
+**`GroupSignedEvent` cannot hold another group's event**, by its foreign key onto
+`ChatRoom`. Worth knowing before designing anything that wants to keep evidence
+about a group this device is not in.
+
+**The description hint from Phase 6 was never built.**
+[subgroups.md](./subgroups.md#the-description-carries-the-parent-too-as-a-hint)
+says `SharedKeyDerivation.describe` gains a `Parent group:` line so a member
+welcomed into a subgroup after its founding learns it is somebody's child.
+`describe(purpose, path)` has no such parameter and emits no such line — so the
+"one member and one problem" that section describes is unaddressed even for one
+parent. It is also unfixable by this design for a *later* parent, since the
+description is baked into the epoch-0 group context and adoption happens after it.
+
+## Rough size
+
+What it estimated, against what it took:
+
+| | estimated | actual |
+|---|---|---|
+| wire | 1 | 2 — `GroupKeyStateEvent` plus the `SubgroupLineage` type beside it |
+| schema | 5 | 7 — two new files (`GroupKeyStateParent`, `LocalGroupKeyState`), the migration, two DAOs, `MantraDatabase`, the builder |
+| managers | 2 | 3 — `MarmotGroupCreation` as well, losing the parameter nothing fed any more |
+| repositories | 4 | 2 — one interface and one implementation; `DkgRepository`'s changes were return types the note did not count |
+| text | 2 | 2 |
+| UI | 5 | 10 — a route, a screen, a view model and a UI state for the picker, plus the detail screen's four and the strings |
+| tests | 8 | 7 files, 53 new cases |
+
+39 files, and the founding flow is not among them: a subgroup is still founded with
+one parent, and the second arrives by adoption.
+
+454 common tests, 962 jvm tests, `m3Audit` meets every budget.