From c17a5be8945769e93969a3655864c2f5b139c280 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 3 Oct 2026 01:08:45 +0200 Subject: [PATCH] docs(subgroups): a plan for making a subgroup from a dialect, an artifact, a translation or a project Four entities, one shape: `id`, `name`, `chatRoomId` and a per-type kind. So the convenience function the title asks for is four field reads, and recording that is most of what this doc does -- the rest is the part that is actually missing. Nothing in the app can say which subgroup a project's room *is*. A name pre-filled into the picker gets a room related to its entity by a string somebody typed once, which is the same bargain a dialect already loses: nothing in the schema makes a dialect unique, so its identity is `(language, country)` and never its name. So the link goes on the birth certificate -- the one artefact in the relationship that is signed, travels, and is already read on both sides -- as an optional `subgroupFor` tag the parent's quorum signs along with the name it already signs. Eight phases, one commit each, one migration. Phases 1-3 are a shippable release on their own and are exactly the cheap version of this feature; 4-7 add the tag, the `DkgSession` columns that carry it past the ceremony, and the reverse view that makes it worth having. Two things Phase 0 found by doing what it says: A dialect has no detail screen, so it is Phase 2 and not a button on a card. The first draft said the opposite; the reversal is recorded in the doc rather than edited out, because the argument against the card is the argument for Phase 7 -- a `ListItem` in a `LazyColumn` has nowhere to put a list of the subgroups made for an entity, and a dialect is the entity a room is most likely to want several rooms for. And the schema number collided. 23, 24 and 25 are taken on an unmerged branch; this takes 23 anyway, because a number is free relative to the merge base and pre-allocating 26 moves the renumbering rather than avoiding it. Co-Authored-By: Claude Opus 5 --- docs/subgroups-for-entities.md | 538 +++++++++++++++++++++++++++++++++ 1 file changed, 538 insertions(+) create mode 100644 docs/subgroups-for-entities.md diff --git a/docs/subgroups-for-entities.md b/docs/subgroups-for-entities.md new file mode 100644 index 00000000..959297f5 --- /dev/null +++ b/docs/subgroups-for-entities.md @@ -0,0 +1,538 @@ +# 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](./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`. + +## 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. + +## The fork: a name, or a signed link + +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](./subgroups.md#informed-consent-and-what-the-parents-admins-are-actually-signing): +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: + + ```bash + 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: + +```kotlin +/** + * 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: + +```kotlin +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 `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: + +```kotlin +data class Loaded( + val dialect: MantraDialect, + val artifacts: List = emptyList(), + val translations: List = 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: + +```kotlin +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](../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`. 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 move. `docs/material-design-conformance.md` states "at least 12 + adaptive API uses, at least 2 navigation components", checked from *below* + because they regress by being removed. Phase 2 adds a screen with + `readableContent()` and a `NavigateBackButton`, so the floor can be raised — + and should be, or a screen deleted later takes the guarantee with it silently. + +## Verifying + +In a fresh worktree, bootstrap the submodule chain first — four nested +submodules, and gradle otherwise dies on `':library' not found`. + +```bash +ANDROID_HOME=/home/sigidli/Android/Sdk ./gradlew :composeApp:compileDebugKotlinAndroid +``` + +```bash +ANDROID_HOME=/home/sigidli/Android/Sdk ./gradlew :composeApp:jvmTest :composeApp:testDebugUnitTest +``` + +```bash +./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. +```