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>
31 KiB
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.
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: 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.subgroupChatRoomIdwould 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:
-
The fork above. B, phased. Record it here if it changes.
-
Schema number. Checked, and it collided: 23, 24 and 25 are all taken on
claude/focused-brattain-433b3b, which is unmerged — it keys aDkgSessionby profile (23), splitsChatRoomMembershipoffChatRoom(24) and replacesChatMessage.isUserMessagewithsenderIdentity(25).mantrais 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 bargainMantraDatabase'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.mdwas 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 -
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
ChatRoomDetailScreeninstead, 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
ListItemin aLazyColumnof 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 whoseCardhas noonClickat all, so a dialect is the one thing in a room a member cannot open and look at. -
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;
Refholds 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:
MantraDialectDaokeeps its two queries unchanged.MantraArtifactDaogainsgetArtifactsByDialectIdandMantraTranslationArtifactVersionDaogainsgetTranslationsByDialectId. BothWHERE dialectId = :dialectId ORDER BY name, matchinggetDialectsByChatRoomId's shape.MantraRepositorygainsgetArtifactsForDialectandgetTranslationsForDialect, andNO_OPgains both returningemptyList().
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:
assembleTagstakesorigin: SubgroupOrigin.Ref? = nulland appends the tag when it is there. Appended after the name and before theptags, and the position is fixed for the same reason the lineage's order is fixed inSubgroupLineage: a reader that predates this takes the tags it knows off the front and ignores the rest, and a tag inserted into the run ofptags would split the admin list for anybody parsing positionally.parseOrigin(tags), returningRef?.certifiesis 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
parseAdminPublicKeysorparseName.
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:
DkgSessiongainsoriginKind: Int?andoriginId: 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.assembleTagstakes the ref and emits the sameSubgroupOriginTag;parseOriginreads it. The doc comment says whatparseParentChatRoomId'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.proposeRitualandacceptProposalfile it, the way they filesubject.DkgRepository.proposeRitualgrows the parameter;NO_OPgrows it too.SelectSubgroupAdminsViewModel.confirmpasses theRefit has held since Phase 3.SubgroupManager.proposeBirthCertificatetakesorigin: SubgroupOrigin.Ref?and passes it toassembleTags;DkgRitualViewModel.proposeBirthCertificatereads 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;SubgroupCeremonyInParentRoomTestor a newSubgroupOriginJvmTest: 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)onorigin == 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, becauseSubgroup.roomis already nullable and already drawn that way. - In flight.
DkgSessionrows in that room with that origin whose stage is neitherFAILEDnorCOMPLETE. The same moveDialectScaffold.pendingInmakes 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:getLatestSubgroupSessionForis 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.mdbeside the multiple-parents one. -
A line in
docs/project-feature.mdunder "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_APIScountsMaterialTheme.breakpoint,BoxWithConstraints,currentWindowAdaptiveInfo, the pane scaffolds andlistPaneWidthFor, andFLOOR_NAVIGATION_COMPONENTScounts the navigation components.readableContent()andNavigateBackButtonare 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.