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

581 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`.
**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](./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](./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 `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:
```kotlin
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:
```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`. 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`.
```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.
```