"Only an admin of this group can make a subgroup", said to the admin who had just made the group. The guard was reading the wrong thing. **`Participant.adminAt` is written in exactly one place**: `MarmotInboundManager.processGroupMembershipChanges`, reached only from `NostrDao` on a `GroupEventResult.CommitProcessed` -- an *arriving* commit. The rows a creator makes come from `getOrCreateChatRoom` and `addMembers`, neither of which sets it. So on the device that created a room, every participant reads as a non-admin, including the creator, until some other member sends a commit it processes. A freshly made `#admins` room has had no commits at all, so a group whose epoch-0 context names three admins has none of them flagged on the one device certain to be one. **The guard now reads `MarmotGroupData.adminPubkeys`** off the room's own MLS state. That is the thing the column is a cache of: MIP-01 stamps it into the epoch-0 group context, every member gets it in their Welcome, and `processGroupMembershipChanges` reads exactly this when it writes the flag. Reading it directly cannot drift from what the group agreed and is right on both sides from the first moment. A room whose group data cannot be read refuses rather than falling back to the column. The compiler caught a second bug while this was being written: the parent's admin list was named `adminPublicKeys`, shadowing the parameter of the same name that holds the *subgroup's* picked admins -- and the ceremony room is derived from that parameter. It is now `parentAdminPublicKeys`, with a comment saying why the two must never be confused. **The column is caught up as well, because the UI still labels members with it.** `MarmotGroupCreation` stamps `adminAt` on the rows of the members the room was created with as admins -- the same list baked into `MarmotGroupData` a few lines above, so nothing new is asserted. Flags only: `processGroupMembershipChanges` also removes participants missing from the MLS tree, and running the whole reconciliation would soft-delete the rows of anyone `addMembers` could not reach, turning a partial invite into a partial membership. Widening a flag is safe; narrowing membership on a best-effort step is not. Swallowed on failure like `adopt` beside it: a missing flag costs a button not being offered, not correctness. **The old fixture was testing nothing.** It built the parent as a NIP-17 room with MLS state bolted on, and `ChatRoom.deriveChatRoomId` returns a 33-byte compressed key (66 hex) while `MarmotGroupData.nostrGroupId` takes 32 -- so `toExtension()` produced an extension `currentMarmotData()` read back as **null**, silently, taking the admin list with it. Every guard test was passing on that null. The parent is now built as what a real one is: the `#admins` room derived from the group's key, with `adminAt` deliberately left null on every row, which is the state the guard has to work in. Four new tests. Two in `SubgroupManagerJvmTest`: an admin is admitted with every `adminAt` still null, and a room with no MLS state has no admin list to consult. Two in a new `MarmotGroupCreationJvmTest`: the creator is flagged an admin of the room they just made, and the room can read its own group data back -- the second asserting `nostrGroupId` is the 64-hex derived id, since the wrong length there fails by returning null rather than by throwing. 397 common tests, 712 jvm tests, `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This is a Kotlin Multiplatform project targeting Android, iOS, Desktop (JVM).
-
/composeApp is for code that will be shared across your Compose Multiplatform applications. It contains several subfolders:
- commonMain is for code that’s common for all targets.
- Other folders are for Kotlin code that will be compiled for only the platform indicated in the folder name. For example, if you want to use Apple’s CoreCrypto for the iOS part of your Kotlin app, the iosMain folder would be the right place for such calls. Similarly, if you want to edit the Desktop (JVM) specific part, the jvmMain folder is the appropriate location.
-
/iosApp contains iOS applications. Even if you’re sharing your UI with Compose Multiplatform, you need this entry point for your iOS app. This is also where you should add SwiftUI code for your project.
Build and Run Android Application
To build and run the development version of the Android app, use the run configuration from the run widget in your IDE’s toolbar or build it directly from the terminal:
- on macOS/Linux
./gradlew :composeApp:assembleDebug - on Windows
.\gradlew.bat :composeApp:assembleDebug
Build and Run Desktop (JVM) Application
To build and run the development version of the desktop app, use the run configuration from the run widget in your IDE’s toolbar or run it directly from the terminal:
- on macOS/Linux
./gradlew :composeApp:run - on Windows
.\gradlew.bat :composeApp:run
Build and Run iOS Application
To build and run the development version of the iOS app, use the run configuration from the run widget in your IDE’s toolbar or open the /iosApp directory in Xcode and run it from there.
Learn more about Kotlin Multiplatform…