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 <noreply@anthropic.com>
539 lines
26 KiB
Markdown
539 lines
26 KiB
Markdown
# 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<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`. 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.
|
||
```
|