a8f6638changed three things that only exist *between* devices, and every test it came with checks one device at a time. A room's key state is now signed into being rather than announced; two devices have to resolve the room's derivation path independently and land on the same event id; and what they sign as is the room's own key. None of that is visible from a single database, so none of it was covered where it could actually break. This drives the whole protocol for real. Two MantraDatabase instances, a share each out of Frost.trustedDealerKeygen, and the wire held by hand: broadcast() queues a MarmotInnerEvent for the outbound pipeline, so ferrying those rows between databases is the group with MLS taken out -- and MLS has no opinion about any of the claims here. Nothing is stubbed; the FROST calls, the room's path resolution, the id rebuild on arrival, the aggregate and its verification all run. It lands in jvmTest rather than commonTest because it needs a database, and MantraDatabaseJvmTest already established that Room's in-memory builder and the host's bundled SQLite work on this target. jvmTest goes 223 -> 235; commonTest is unchanged at 217. The ferry keeps its already-delivered set on the *receiving* device rather than the sending one. A sender-side set looked equivalent and was not: a message goes to every other device, so the first delivery hid it from everybody else, and the three-member test failed because the member who never signed never got the signature. That is a bug in the fake wire rather than in the group, and it is the kind a two-device test would never have shown. ## What is pinned - propose() writes no state of its own. The creator has decided nothing until a quorum signs, which is the whole point of it no longer being an announcement. - The payload of the room's first message is a 30326 authored by the room's id -- not the group's root key, which is what an untweaked cache produces and what this used to be. - A second device with no path in its room metadata still rebuilds the same event id. It resolves the path itself and checks it against the room's id, so agreeing on the id is agreeing on every byte signed, the author included. - A member who has not approved publishes nothing. - Two devices that both applied the signature hold the same state, attributed to the room rather than to anybody in particular. - The finished signature verifies against the room's id, and passes GroupKeyStateEvent.isSignedByGroup, which is the check a receiver runs. - The third member of a 2-of-3 picks up the state without ever being asked to sign, because completing needs nothing of theirs. - A dialect signed in the room is authored by the room, and its stored signature verifies against the room's id. That is the half ofa8f6638that touches artifacts and dialects, end to end for the first time. - A proposer cannot choose the key the group signs as: a proposal carrying a true key state re-authored under the root key opens no session at all, because the receiver rebuilds the id under its own reading of the room. - A true key state nobody signed no longer becomes a row. This is the behaviour change worth being able to point at. - A room derived at m/9420/0/1 signs at m/9420/0/1, so nothing has quietly hardcoded MARMOT_ADMIN_GROUP_PATH. - A room not derived from the key at all still signs as the threshold key, with a null derivationPath -- the fallback kept for rooms the app no longer makes. ## They were checked against mutations, not just run Tests that pass are not evidence until something makes them fail. Four deliberate regressions were introduced and reverted: - unsignedEventOf deriving at the empty path instead of the room's: 7 failed. - signingPath returning its first candidate without checking it derives the room: 1 failed, the not-derived room. - propose writing the state row locally, the way announce() did: 4 failed. - isSignedByGroup accepting an author that is not the room: 7 failed, 3 of them in the existing GroupKeyStateTest. 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…