Files
mantra-kmp/docs/subgroups-for-entities.md
Kgothatso Ngako 2f542408c9
Some checks failed
Material Design conformance / budgets (push) Has been cancelled
Material Design conformance / tests (push) Has been cancelled
docs(subgroups): the dialect catalogue is in the tree now, so cite it again
Written while the catalogue was on an unmerged branch, so three citations were
replaced with the reasoning stated directly. It landed on mantra during the
build and the merge brought it in, so `DialectScaffold`, `AfricanLanguages` and
docs/african-dialect-catalogue.md are all here.

The restated reasoning stays rather than turning back into links -- a plan that
explains its own rule survives the file it borrowed it from being moved -- but
two cross-references are worth having. `DialectScaffold` is the closest thing in
the tree to `SubgroupOrigin`, and `DialectScaffold.pendingIn` is Phase 7's
`ceremoniesFor` solving the same race one layer up: read what is already in
flight, so two admins looking at the same screen do not propose the same thing
twice. Eighty dialects there, two rooms for one project here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 03:35:38 +02:00

31 KiB
Raw Blame History

A subgroup for a dialect, an artifact, a translation or a project

A group's room holds a library, a set of dialects and a list of projects. Making a room for one of them — a differently-keyed group whose work is that artifact, that dialect, that translation, that project — takes seven screens today: back out to the room, find "Add subgroup", retype the name, pick the admins. Every part of that except the admins is already known from the thing the user was looking at.

This is the plan for one call that answers it, what the call hands back, the one place the answer has to be written down so the room is still connected to the entity tomorrow, and the screen a dialect turns out to need before it can be asked at all.

Read subgroups.md first — the four steps, what the birth certificate covers, and why the child's key is fresh. Nothing here changes any of that. Written against the code as it stood at 5875655a.

Built, eight phases, one commit each. The phases below are kept as the reasoning they were, because the code reads better against the argument it came from than against a summary of itself. Where the implementation chose differently the table says so, and it did so seven times worth reading:

what the plan said what it turned out to be
schema 23 is the next free number 23, 24 and 25 are all taken on an unmerged branch. Taken anyway, and Phase 0.2 says why: a number is free relative to the merge base, and pre-allocating 26 moves the renumbering rather than avoiding it
proposeBirthCertificate takes an origin parameter, passed down from the view model it already takes the whole DkgSession, so it reads key.origin(). A parameter would have been a second source for one fact, and the proposer's copy of it at that. Nothing in the repository or the ritual view model changed
Phase 3 puts a button on each of four screens Phase 7 replaced all four with one LazyListScope extension. The buttons were written twice, and the section should have been planned in Phase 3 — the giveaway was there in Phase 0.3, which already argued the entry point had to grow into a list
ArtifactDetailUIState gains a capability flag that, and a ChatRepository in the screen and the view model. ProjectDetailViewModel needed one too, which was not anticipated: canSign alone cannot answer whether a room is a Marmot one
Phase 8 raises the M3 adaptive floor it cannot. The floors count MaterialTheme.breakpoint, BoxWithConstraints, the pane scaffolds and the navigation components; readableContent() and NavigateBackButton are neither. A new screen conforming to the rules adds nothing to either count, and raising a floor the work did not earn would make it regress for an unrelated reason
the card says what a subgroup is for in its supporting line an overline. The supporting line was already carrying whether this device can open the room, which is a different question and both are worth a line
the empty-dialect test deletes the rows neither DAO has a @Delete, and adding one for a test is a schema surface nobody asked for. It seeds a third dialect with nothing in it, which is also the truer fixture — a room defines a dialect before it has anything in it

One more worth stating because it was a drafting error rather than a design one, and because it resolved itself. The first draft cited DialectScaffold, AfricanLanguages and african-dialect-catalogue.md as precedent; at the time none of them were in the tree, so the citations were replaced with the reasoning stated directly. The dialect catalogue landed on mantra while this was being built, so all three are here now and the cross-references below are real again — but the restated reasoning is kept rather than turned back into links, because a plan that explains its own rule survives the file it borrowed it from being moved.

Two of them are worth following. DialectScaffold is the closest thing in the tree to Phase 1's SubgroupOrigin: pure, in managers/, derived from the rows rather than beside them. And DialectScaffold.pendingIn is Phase 7's ceremoniesFor solving the same problem one layer up — it reads the proposals already in flight so two admins looking at the same list do not propose the same forty dialects twice. The subgroup version of that race is two rooms for one project instead of eighty dialects, which is rarer and more expensive.

What the four have in common

entity kind name room
MantraDialect 30304 name chatRoomId
MantraArtifact 30300 name chatRoomId
MantraTranslationArtifactVersion 30306 name chatRoomId
MantraProject 30330 name chatRoomId

Three fields, the same three on all four, plus a kind that is a per-type constant. That is the whole of what a subgroup needs to be started: the parent is chatRoomId, the proposed name is name, and the thing it is for is (kind, id).

It is worth saying plainly how little of this feature is the convenience function, because it decides where the effort goes. Deriving (parent, name) from an entity is four field reads. What is actually missing is the other direction: nothing in the app can say which subgroup a project's room is, and without that the convenience function produces rooms that are related to their entity by a string somebody typed once.

This decision changes the shape of the plan and should be made now rather than discovered in Phase 6.

Option A — pre-fill only. SubgroupOrigin.of(project) gives (parentChatRoomId, name), the picker opens with the name filled in, and the ceremony runs exactly as it does today. One pure file, four buttons, no schema change, no new tag. A room called #Setswana Luke and a translation called Setswana Luke are then connected by nothing a reader can check, and ChatRoomDetailScreen lists the subgroup under "subgroups" with no idea what it is for. Two subgroups for one project are indistinguishable from two subgroups with similar names.

Option B — the certificate says what it is for. The birth certificate grows one optional tag naming (kind, id), and that is the link: signed by the parent's quorum, carried on the child's key state, checkable by every member, and already read by every screen that reads subgroups.

Recommended: B, phased so that A ships inside it. Phases 1–3 are the pure function, a dialect's screen and the entry points, which are useful on their own and are exactly Option A; Phases 4–7 add the tag, the column that carries it and the reverse view. Stopping after Phase 3 is a legitimate release, and nothing in Phases 1–3 has to be redone to continue.

The argument for putting the link on the certificate rather than anywhere else is the argument the certificate was designed around, and it is in subgroups.md: the tags carry what makes the claim checkable, and the signature covers them because an event id hashes over its tags. A parent admin is already being asked to sign a room id produced by a ceremony most of them were not in. "This is the room for the Setswana Luke translation" is the single most useful sentence that can be put in front of them, and it costs one tag.

The alternatives were considered and are worse:

  • A column on the entity. MantraProject.subgroupChatRoomId would be a local column on a quorum-signed, content-hashed row. Either it goes into the event — and then the id changes, and the project cannot name a room that does not exist until after the project is signed — or it does not, and it is a fact one device holds and no other member can see.
  • A new table. A join table from entity to room, written locally. Same problem one layer out: nothing signs it, so it is this device's note.
  • The room's subject. Already taken by the name, and a name is not an id. A dialect is the clearest case: nothing in the schema makes one unique — the id is a content hash including createdAt, there is no index, and the rows are quorum-signed so they cannot be rewritten — so a dialect's identity is its (language, country) pair and never its name. A room that typed "Southern Sotho" where another typed "Sesotho" holds the same dialect, and two rooms named for it are two rooms with no checkable relation to it.

The certificate is the only artefact in the relationship that is signed, travels, and is already read on both sides.

What the tag is, and what it is not

["subgroupFor", "30330", "<64-hex event id>"]

Kind first, id second, both required for the tag to parse. The kind is not redundant: an id alone would have to be looked up in four tables to be understood, and a reader that does not know the kind cannot tell "a project this device does not hold" from "not a project at all". The precedent is PayloadKindTag/PayloadIdTag, which carry the same pair for the same reason — so a client can decide what it is looking at without parsing the thing itself.

It authenticates nothing about the entity. The parent's quorum is signing that it intends this room to be for that entity; it is not vouching that the entity exists, that this device can resolve it, or that the entity's own signature is good. That is the same limit docs/project-feature.md states for a cross-room project membership — "the group asserts the membership; it does not vouch for the thing named, which no amount of signing can" — and it is the correct limit here too, because a subgroup's members need never have been in the parent and so need never hold the parent's rows.

It is never checked by certifies. The founding-roster rule in SubgroupBirthCertificateEvent says the name and the p tags are history and are not compared against anything current, and the origin joins them. A certificate with no origin tag is valid — every certificate signed before this feature has none — and a certificate naming an entity this device cannot resolve is valid. There is a test whose job is to fail the day somebody adds the comparison that looks obviously missing, and this adds a second case to it.

Phase 0 — the decisions, written down

Before any code:

  1. The fork above. B, phased. Record it here if it changes.

  2. Schema number. Checked, and it collided: 23, 24 and 25 are all taken on claude/focused-brattain-433b3b, which is unmerged — it keys a DkgSession by profile (23), splits ChatRoomMembership off ChatRoom (24) and replaces ChatMessage.isUserMessage with senderIdentity (25). mantra is at 22.

    Taken anyway: 23, with AutoMigration(from = 22, to = 23), because the merge base is what a number is free relative to and pre-allocating 26 does not avoid the work. Room needs a continuous path from the version on a user's device: if that branch lands first the edge here has to become 25 → 26 regardless, and if it is itself renumbered or abandoned a pre-allocated 26 leaves three dead numbers behind. Whichever tree merges second renumbers, one commit, which is the bargain MantraDatabase's v22 comment already records.

    The check is worth doing even though it did not change the answer, which is the point docs/project-feature.md was making: it went wrong by checking the wrong tree, not by failing to check. The sweep that found this:

    for b in $(git for-each-ref --format='%(refname:short)' refs/heads/); do
        git show "$b:composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt" 2>/dev/null |
            grep -m1 "^    version = " | sed "s|$| <- $b|"
    done | sort -u
    
  3. The dialect entry point. Settled: a dialect gets its own screen, which is Phase 2. The first draft of this plan recommended hanging the action off the dialect card in ChatRoomDetailScreen instead, on the grounds that a whole screen to hold one button is a screen somebody has to keep. That was wrong on its own terms twice over, and the reversal is recorded rather than quietly edited out because the argument against it is the argument for Phase 7.

    A dialect is the one of the four with no screen, so the card would have been the only entry point that cannot grow: Phase 7 gives every entity a list of the subgroups made for it and the ceremonies in flight for it, and a ListItem in a LazyColumn of dialects has nowhere to put either. The asymmetry would land exactly where it hurts, since a dialect is the entity a room is most likely to want several rooms for. And the card is already the thinnest surface in that screen — it is the only one of the three lists whose Card has no onClick at all, so a dialect is the one thing in a room a member cannot open and look at.

  4. Whether a second subgroup for one entity is refused. Recommended: no. Two teams working on one artifact in two rooms is a real thing and refusing it would be this device deciding a group's structure. What Phase 7 does instead is show what already exists, which is what prevents the accidental second one.

Phase 1 — SubgroupOrigin, and the trait that feeds it

One commit. Pure, commonMain, no database and no Compose.

A trait in database/model/traits/, implemented by the four entities:

/**
 * An entity a subgroup can be made for.
 *
 * Four rows in three tables have the same three fields and a per-type kind, and
 * this is what makes "which things can a subgroup be made for" a list the
 * compiler holds rather than four overloads somebody has to remember to extend.
 */
interface SubgroupOriginEntity {
    val id: HexKey
    val name: String
    val chatRoomId: String
    val subgroupOriginKind: Kind
}

MantraDialect, MantraArtifact, MantraTranslationArtifactVersion and MantraProject each gain override on the three fields they already have and one line for the kind. No toXEvent or tag order changes — the trait is about naming a room, touches no event, and so cannot move an event id.

Then the function the whole plan is named after:

object SubgroupOrigin {
    /** What a certificate names: the kind and the id, together or not at all. */
    data class Ref(val kind: Kind, val id: HexKey)

    /** Everything a picker needs to open a subgroup for [entity]. */
    data class Candidate(
        val parentChatRoomId: String,
        val name: String,
        val origin: Ref,
    )

    fun of(entity: SubgroupOriginEntity): Candidate
}

Parallel to DialectScaffold, TranslationScaffold and ChapterScaffolding: managers/, pure, tested in commonTest. name is trimmed and handed over bare — the # is added by MarmotGroupName.of where the name is used, twice, idempotently, exactly as the typed name is today.

Tests (commonTest, SubgroupOriginTest):

  • one case per entity type, asserting parent, name and ref;
  • a blank-named entity yields a blank candidate name rather than inventing one, so the picker's "Give the subgroup a name." refusal still fires;
  • Ref holds a kind and an id and there is no constructor that holds one.

Phase 2 — a dialect gets a screen

One commit. No new machinery, two new queries, and the only phase here that is not about subgroups at all.

Artifacts, translations and projects each have a route, a UI state, a view model and a screen. A dialect has a ListItem inside ChatRoomDetailScreen's LazyColumn with no onClick, so it is the one entity in a room that cannot be opened. Phase 3 needs somewhere to put an action and Phase 7 needs somewhere to put two lists, and this is that place.

Follow ArtifactDetailViewModel exactly — it is the closest sibling and the shortest, which is the point of copying it rather than the richer ones:

piece shape
DialectDetailRoute activeUserPublicKey, dialectId, chatRoomId, relayHint
DialectDetailUIState sealed Loading / Error / Loaded
DialectDetailViewModel initiateDialectDetail(), a factory, MantraRepository
DialectDetailScreen registered in MantraNavHost beside ArtifactDetailRoute

Loaded carries the dialect, what is written in it, and the capability flag:

data class Loaded(
    val dialect: MantraDialect,
    val artifacts: List<MantraArtifact> = emptyList(),
    val translations: List<MantraTranslationArtifactVersion> = emptyList(),
    val canAddSubgroup: Boolean = false,
) : DialectDetailUIState

Those two lists are what a dialect screen is for — a dialect on its own is three strings and a screen showing only them has no reason to exist. They are also the two tables that carry a dialectId: MantraArtifact.dialectId is the dialect an artifact is written in, and MantraTranslationArtifactVersion.dialectId is the dialect a translation is into. Both are plain columns and neither has a query yet:

  • MantraDialectDao keeps its two queries unchanged.
  • MantraArtifactDao gains getArtifactsByDialectId and MantraTranslationArtifactVersionDao gains getTranslationsByDialectId. Both WHERE dialectId = :dialectId ORDER BY name, matching getDialectsByChatRoomId's shape.
  • MantraRepository gains getArtifactsForDialect and getTranslationsForDialect, and NO_OP gains both returning emptyList().

Scope it to the dialect, not to the room. A dialectId is a foreign key inside one room today — see the one-room scope decision in docs/project-feature.md — so filtering by the dialect alone cannot reach another room's rows, and adding AND chatRoomId = :chatRoomId would be a second spelling of a constraint the key already enforces. The day projects go cross-room that stops being true, and the query is then one of the places that has to learn about it.

The screen itself: TopAppBar with NavigateBackButton(onNavigateBack), because this is a pushed screen and the desktop target has no system back — twenty-two screens were dead ends before that rule. ScreenStateTransition over the state when, as the whole composable's body. Four states, with EmptyState given a real message for each of the two lists, since one shared default is how five different absences all came to say the same thing. Language and country through the shared DetailRow widget. readableContent() on the content root.

Then the dialect card in ChatRoomDetailScreen gets the onClick it never had, pointing at the new route — which is a fix independent of everything else here and is the reason this phase stands alone.

Tests (jvmTest, DialectDetailJvmTest): a dialect with two artifacts and one translation in it loads all three; an artifact in a different dialect in the same room is not listed; a missing dialect id yields Error rather than an empty Loaded.

Phase 3 — four entry points

One commit. This is where Option A is complete and shippable.

SelectSubgroupAdminsRoute grows three nullable fields:

val name: String? = null,
val originKind: Int? = null,
val originId: String? = null,

Flat strings because a route is @Serializable and carries no domain types. Nullable because the room's own "Add subgroup" button passes none and must keep working unchanged.

SelectSubgroupAdminsViewModel takes them, seeds name.value from name on construction rather than in initiate() — a field the user may already be typing into must not be overwritten by a load completing — and holds the Ref for Phase 5. The name stays editable: a pre-fill is a suggestion, and a group renaming its own room is not this plan's business to prevent.

The four buttons, each enabled = canAddSubgroup beside the actions already there:

screen where
ArtifactDetailScreen beside the existing signing actions
ProjectDetailScreen same
TranslationArtifactVersionDetailScreen same
DialectDetailScreen the screen Phase 2 built

The gate is canAddSubgroup's, not canSign alone: a share of the key and mlsGroupState != null, because a NIP-17 room has no admins to be a parent's and these entities can live in one. Name the flag canAddSubgroup on all four states so there is one spelling of one rule.

canSign is the half that is already there, and ProjectDetailUIState and TranslationArtifactVersionDetailUIState already carry it beside the signing actions they already draw. ArtifactDetailUIState does not — it has artifact, versions, chapters and translations and no capability flag at all — so it gains one, read the same way the other two read it, from frostSigningRepository.canSign. That is a genuine addition and the one place this phase is more than a button. DialectDetailUIState carries the flag from the moment Phase 2 writes it, so only the artifact screen is short.

It is not the whole guard — refuseSubgroup still runs in the picker and again before anything is published — but it is the one that decides whether the button is drawn.

M3, per CLAUDE.md: one new string in composeResources/values/strings.xml, sentence case; spacing from MaterialTheme.spacing; the dialect card's icon button gets a real contentDescription, since "make a subgroup" is not repeated by any text beside it; the card's existing decorative icons stay Decorative.

Phase 4 — the tag

One commit.

nostr/subgroup/tags/SubgroupOriginTag.kt, following PayloadKindTag's shape exactly — TAG_NAME, parse with ensure(tag.has(2)), toIntOrNull() for the kind, assemble. A tag with one of the two values parses to null, because half a reference is a reference with the checkable part removed.

SubgroupBirthCertificateEvent:

  • assembleTags takes origin: SubgroupOrigin.Ref? = null and appends the tag when it is there. Appended after the name and before the p tags, and the position is fixed for the same reason the lineage's order is fixed in SubgroupLineage: a reader that predates this takes the tags it knows off the front and ignores the rest, and a tag inserted into the run of p tags would split the admin list for anybody parsing positionally.
  • parseOrigin(tags), returning Ref?.
  • certifies is not touched. Add the case to the test that guards the founding-roster rule: a certificate whose origin names an entity this device does not hold still certifies.

Tests (commonTest, extending SubgroupBirthCertificateEventTest):

  • assemble → parse round trip for each of the four kinds;
  • a certificate with no origin tag parses to null and still certifies;
  • a malformed origin (missing id, non-numeric kind, 63-hex id) parses to null rather than to a half-reference;
  • the tag does not disturb parseAdminPublicKeys or parseName.

Phase 5 — carrying it through the ceremony, and schema 23

One commit. This is the only phase with a migration.

The certificate is proposed after the ceremony and may be proposed by an admin who is not the coordinator — SubgroupManager's whole shape is that every step reads stored rows rather than a session object handed down from the screen that started it. So the origin has to survive on the DkgSession, and it has to reach every participant's session, not only the coordinator's.

The precedent is exact. v19 added subject and participantPublicKeys to DkgSession for precisely this reason, and v17 added parentChatRoomId; all three are nullable, none is a foreign key, and all three are read off the ritual proposal's tags. This follows them line for line:

  • DkgSession gains originKind: Int? and originId: HexKey?. Nullable, no default, not foreign keys — an origin routinely names a row a subgroup member does not hold, so a dangling value is the normal state and a cascade would mean deleting a project deletes a room.
  • MantraDatabase → version = 23, AutoMigration(from = 22, to = 23), with a comment in the house style saying what reads back null and why that is the true answer for those rows: a ceremony that ran before this was opened for no entity.
  • DkgRitualEvents.assembleTags takes the ref and emits the same SubgroupOriginTag; parseOrigin reads it. The doc comment says what parseParentChatRoomId's says and for the same reason — this authenticates nothing, anybody can put any id here, and the claim that decides is the certificate two steps later.
  • ChillDkgRitualManager.proposeRitual and acceptProposal file it, the way they file subject.
  • DkgRepository.proposeRitual grows the parameter; NO_OP grows it too.
  • SelectSubgroupAdminsViewModel.confirm passes the Ref it has held since Phase 3.
  • SubgroupManager.proposeBirthCertificate takes origin: SubgroupOrigin.Ref? and passes it to assembleTags; DkgRitualViewModel.proposeBirthCertificate reads it off the session — session.originKind/session.originId — never off a route, for the same reason it reads the parent off the state and not the route.

Tests (jvmTest):

  • SubgroupDaoJvmTest: a session round trips both columns, and a row with both null reads back as no origin;
  • SubgroupCeremonyInParentRoomTest or a new SubgroupOriginJvmTest: proposal → every participant's session → certificate tags, asserting the id that comes out of the signed certificate is the one that went into the picker;
  • a ceremony opened with no origin produces a certificate with no origin tag, which is the room's own "Add subgroup" path and must not change.

Phase 6 — what the signer and the room see

One commit, no schema.

SubgroupManager.Subgroup gains origin: SubgroupOrigin.Ref?, parsed from the certificate it already parses the name and admins from. Nothing else about that type changes, so every existing reader keeps working.

ProposedEvent.summarize's 30329 branch gains the origin, resolved to a name where the device holds the row and shown as a kind plus a short hash where it does not — describe's existing bargain, and the comment there already says why a short hash in front of somebody deciding whether to sign is worth stating rather than hiding. This is the phase that pays for the tag: a parent admin reads "a new subgroup of this group · Setswana Luke · for the translation Setswana Luke · run by 4 members" instead of a name and a count.

SubgroupCard in ChatRoomDetailScreen gains a supporting line saying what the subgroup is for, on the same terms.

Resolution needs a lookup by id across four tables. MantraRepository already has getDialect, getArtifact, getTranslation and getProject, each taking an id — so this is one when on the kind over functions that exist, and deliberately not a new DAO query.

Phase 7 — the other direction

One commit, no schema. The phase that makes the link worth having.

Each of the four entity surfaces shows the subgroups made for it, and in-flight ceremonies for it:

  • Made. Filter chatRepository.subgroupsOf(entity.chatRoomId) on origin == SubgroupOrigin.of(entity).origin. No new query: the parent's certificates are already loaded by the room's screen, and the entity's screen reads the same list. A subgroup whose room this device does not hold still shows, because Subgroup.room is already nullable and already drawn that way.
  • In flight. DkgSession rows in that room with that origin whose stage is neither FAILED nor COMPLETE. The same move DialectScaffold.pendingIn makes for the dialect catalogue, and for the same reason. Two admins both open the project's screen, both see no room, and the group gets two ceremonies and two rooms for one project — a race this feature would hit on first use, because the screen that invites the act is the screen that cannot see the act in progress. Making the open ceremony readable at the moment of proposing is what stops it, and it is cheap: getLatestSubgroupSessionFor is already the shape of the query.

Nothing is refused (Phase 0.4). The button reads differently when a room exists — "open the project's room", or the add action beside a list of one — and that is the whole intervention.

Tests (jvmTest): a VM whose room holds two subgroups, one for this entity and one for another, lists exactly one; an entity with a ceremony in flight shows it; a completed ceremony is not shown as in flight.

Phase 8 — docs and the audit

One commit.

  • This file, updated to say what the implementation chose differently. Every doc in docs/ carries that table and it is the most-read part of them.

  • A pointer from docs/subgroups.md beside the multiple-parents one.

  • A line in docs/project-feature.md under "what this does not do", since a project's room is the first thing that file says it lacks.

  • The M3 floors do not move, and the reason is worth keeping. The plan said Phase 2's new screen would let the adaptive floor be raised. It does not: FLOOR_ADAPTIVE_APIS counts MaterialTheme.breakpoint, BoxWithConstraints, currentWindowAdaptiveInfo, the pane scaffolds and listPaneWidthFor, and FLOOR_NAVIGATION_COMPONENTS counts the navigation components. readableContent() and NavigateBackButton are in neither list.

    So a screen that follows every rule in CLAUDE.md adds nothing to either count, and raising a floor to the number the tree happens to sit at would make it fail the next time something unrelated was removed. The floors guard two-pane layout and the navigation shell; a detail screen is neither. Both stay where phase 6 of the conformance work left them.

Verifying

In a fresh worktree, bootstrap the submodule chain first — four nested submodules, and gradle otherwise dies on ':library' not found.

ANDROID_HOME=/home/sigidli/Android/Sdk ./gradlew :composeApp:compileDebugKotlinAndroid
ANDROID_HOME=/home/sigidli/Android/Sdk ./gradlew :composeApp:jvmTest :composeApp:testDebugUnitTest
./gradlew :composeApp:m3Audit

IdentitySwitchJvmTest's "last profile opened" case fails about one run in nine on a clean tree; check the base before blaming a change here.

What this does not do

  • It does not make a subgroup cheaper. Four multi-party rounds, every one needing every selected admin present. The convenience is in the naming and the link, not in the ceremony, and a group that wants a room per chapter is going to find that out.
  • It does not move the entity. A project whose room is a subgroup is still a row in the parent's tables, signed by the parent's quorum, and the child's members may hold none of it. Giving the child its own copy is a cross-room question and is Phase 9 of docs/project-feature.md, not this.
  • It does not resolve an origin across rooms. A certificate may name an entity this device cannot see, and the UI shows a kind and a short hash rather than pretending.
  • It does not make the origin evidence. The parent asserts what the room is for. Nothing here checks that the entity exists, and nothing can.