Files
mantra-kmp/composeApp
Kgothatso Ngako ccaef5d36a fix(subgroups): read the admin list off the group's data, not off a flag no creator ever sets
"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>
2026-09-09 00:30:53 +02:00
..
2026-09-08 09:11:13 +02:00