docs(subgroups): a plan for making a subgroup from a dialect, an artifact, a translation or a project
Four entities, one shape: `id`, `name`, `chatRoomId` and a per-type kind. So the convenience function the title asks for is four field reads, and recording that is most of what this doc does -- the rest is the part that is actually missing. Nothing in the app can say which subgroup a project's room *is*. A name pre-filled into the picker gets a room related to its entity by a string somebody typed once, which is the same bargain a dialect already loses: nothing in the schema makes a dialect unique, so its identity is `(language, country)` and never its name. So the link goes on the birth certificate -- the one artefact in the relationship that is signed, travels, and is already read on both sides -- as an optional `subgroupFor` tag the parent's quorum signs along with the name it already signs. Eight phases, one commit each, one migration. Phases 1-3 are a shippable release on their own and are exactly the cheap version of this feature; 4-7 add the tag, the `DkgSession` columns that carry it past the ceremony, and the reverse view that makes it worth having. Two things Phase 0 found by doing what it says: A dialect has no detail screen, so it is Phase 2 and not a button on a card. The first draft said the opposite; the reversal is recorded in the doc rather than edited out, because the argument against the card is the argument for Phase 7 -- a `ListItem` in a `LazyColumn` has nowhere to put a list of the subgroups made for an entity, and a dialect is the entity a room is most likely to want several rooms for. And the schema number collided. 23, 24 and 25 are taken on an unmerged branch; this takes 23 anyway, because a number is free relative to the merge base and pre-allocating 26 moves the renumbering rather than avoiding it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
538
docs/subgroups-for-entities.md
Normal file
538
docs/subgroups-for-entities.md
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
# A subgroup for a dialect, an artifact, a translation or a project
|
||||||
|
|
||||||
|
A group's room holds a library, a set of dialects and a list of projects. Making
|
||||||
|
a room for one of them — a differently-keyed group whose work *is* that artifact,
|
||||||
|
that dialect, that translation, that project — takes seven screens today: back
|
||||||
|
out to the room, find "Add subgroup", retype the name, pick the admins. Every
|
||||||
|
part of that except the admins is already known from the thing the user was
|
||||||
|
looking at.
|
||||||
|
|
||||||
|
This is the plan for one call that answers it, what the call hands back, the one
|
||||||
|
place the answer has to be written down so the room is still connected to the
|
||||||
|
entity tomorrow, and the screen a dialect turns out to need before it can be
|
||||||
|
asked at all.
|
||||||
|
|
||||||
|
Read [subgroups.md](./subgroups.md) first — the four steps, what the birth
|
||||||
|
certificate covers, and why the child's key is fresh. Nothing here changes any of
|
||||||
|
that. Written against the code as it stood at `5875655a`.
|
||||||
|
|
||||||
|
## What the four have in common
|
||||||
|
|
||||||
|
| entity | kind | name | room |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `MantraDialect` | 30304 | `name` | `chatRoomId` |
|
||||||
|
| `MantraArtifact` | 30300 | `name` | `chatRoomId` |
|
||||||
|
| `MantraTranslationArtifactVersion` | 30306 | `name` | `chatRoomId` |
|
||||||
|
| `MantraProject` | 30330 | `name` | `chatRoomId` |
|
||||||
|
|
||||||
|
Three fields, the same three on all four, plus a kind that is a per-type
|
||||||
|
constant. That is the whole of what a subgroup needs to be started: the parent is
|
||||||
|
`chatRoomId`, the proposed name is `name`, and the thing it is for is
|
||||||
|
`(kind, id)`.
|
||||||
|
|
||||||
|
It is worth saying plainly how little of this feature is the convenience
|
||||||
|
function, because it decides where the effort goes. Deriving `(parent, name)`
|
||||||
|
from an entity is four field reads. What is actually missing is the other
|
||||||
|
direction: **nothing in the app can say which subgroup a project's room is**, and
|
||||||
|
without that the convenience function produces rooms that are related to their
|
||||||
|
entity by a string somebody typed once.
|
||||||
|
|
||||||
|
## The fork: a name, or a signed link
|
||||||
|
|
||||||
|
This decision changes the shape of the plan and should be made now rather than
|
||||||
|
discovered in Phase 6.
|
||||||
|
|
||||||
|
**Option A — pre-fill only.** `SubgroupOrigin.of(project)` gives
|
||||||
|
`(parentChatRoomId, name)`, the picker opens with the name filled in, and the
|
||||||
|
ceremony runs exactly as it does today. One pure file, four buttons, no schema
|
||||||
|
change, no new tag. A room called `#Setswana Luke` and a translation called
|
||||||
|
`Setswana Luke` are then connected by nothing a reader can check, and
|
||||||
|
`ChatRoomDetailScreen` lists the subgroup under "subgroups" with no idea what it
|
||||||
|
is for. Two subgroups for one project are indistinguishable from two subgroups
|
||||||
|
with similar names.
|
||||||
|
|
||||||
|
**Option B — the certificate says what it is for.** The birth certificate grows
|
||||||
|
one optional tag naming `(kind, id)`, and that is the link: signed by the
|
||||||
|
parent's quorum, carried on the child's key state, checkable by every member, and
|
||||||
|
already read by every screen that reads subgroups.
|
||||||
|
|
||||||
|
**Recommended: B, phased so that A ships inside it.** Phases 1–3 are the pure
|
||||||
|
function, a dialect's screen and the entry points, which are useful on their own
|
||||||
|
and are exactly Option A; Phases 4–7 add the tag, the column that carries it and
|
||||||
|
the reverse view. Stopping after Phase 3 is a legitimate release, and nothing in
|
||||||
|
Phases 1–3 has to be redone to continue.
|
||||||
|
|
||||||
|
The argument for putting the link on the certificate rather than anywhere else is
|
||||||
|
the argument the certificate was designed around, and it is in
|
||||||
|
[subgroups.md](./subgroups.md#informed-consent-and-what-the-parents-admins-are-actually-signing):
|
||||||
|
the tags carry what makes the claim checkable, and the signature covers them
|
||||||
|
because an event id hashes over its tags. A parent admin is already being asked
|
||||||
|
to sign a room id produced by a ceremony most of them were not in. "This is the
|
||||||
|
room for the Setswana Luke translation" is the single most useful sentence that
|
||||||
|
can be put in front of them, and it costs one tag.
|
||||||
|
|
||||||
|
The alternatives were considered and are worse:
|
||||||
|
|
||||||
|
- **A column on the entity.** `MantraProject.subgroupChatRoomId` would be a local
|
||||||
|
column on a quorum-signed, content-hashed row. Either it goes into the event —
|
||||||
|
and then the id changes, and the project cannot name a room that does not exist
|
||||||
|
until after the project is signed — or it does not, and it is a fact one device
|
||||||
|
holds and no other member can see.
|
||||||
|
- **A new table.** A join table from entity to room, written locally. Same
|
||||||
|
problem one layer out: nothing signs it, so it is this device's note.
|
||||||
|
- **The room's subject.** Already taken by the name, and a name is not an id. A
|
||||||
|
dialect is the clearest case: nothing in the schema makes one unique — the id
|
||||||
|
is a content hash including `createdAt`, there is no index, and the rows are
|
||||||
|
quorum-signed so they cannot be rewritten — so a dialect's identity is its
|
||||||
|
`(language, country)` pair and never its name. A room that typed "Southern
|
||||||
|
Sotho" where another typed "Sesotho" holds the same dialect, and two rooms
|
||||||
|
named for it are two rooms with no checkable relation to it.
|
||||||
|
|
||||||
|
The certificate is the only artefact in the relationship that is signed, travels,
|
||||||
|
and is already read on both sides.
|
||||||
|
|
||||||
|
## What the tag is, and what it is not
|
||||||
|
|
||||||
|
```
|
||||||
|
["subgroupFor", "30330", "<64-hex event id>"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Kind first, id second, both required for the tag to parse. The kind is not
|
||||||
|
redundant: an id alone would have to be looked up in four tables to be
|
||||||
|
understood, and a reader that does not know the kind cannot tell "a project this
|
||||||
|
device does not hold" from "not a project at all". The precedent is
|
||||||
|
`PayloadKindTag`/`PayloadIdTag`, which carry the same pair for the same reason —
|
||||||
|
so a client can decide what it is looking at without parsing the thing itself.
|
||||||
|
|
||||||
|
**It authenticates nothing about the entity.** The parent's quorum is signing that
|
||||||
|
*it intends* this room to be for that entity; it is not vouching that the entity
|
||||||
|
exists, that this device can resolve it, or that the entity's own signature is
|
||||||
|
good. That is the same limit `docs/project-feature.md` states for a cross-room
|
||||||
|
project membership — "the group asserts the membership; it does not vouch for the
|
||||||
|
thing named, which no amount of signing can" — and it is the correct limit here
|
||||||
|
too, because a subgroup's members need never have been in the parent and so need
|
||||||
|
never hold the parent's rows.
|
||||||
|
|
||||||
|
**It is never checked by `certifies`.** The founding-roster rule in
|
||||||
|
`SubgroupBirthCertificateEvent` says the name and the `p` tags are history and
|
||||||
|
are not compared against anything current, and the origin joins them. A
|
||||||
|
certificate with no origin tag is valid — every certificate signed before this
|
||||||
|
feature has none — and a certificate naming an entity this device cannot resolve
|
||||||
|
is valid. There is a test whose job is to fail the day somebody adds the
|
||||||
|
comparison that looks obviously missing, and this adds a second case to it.
|
||||||
|
|
||||||
|
## Phase 0 — the decisions, written down
|
||||||
|
|
||||||
|
Before any code:
|
||||||
|
|
||||||
|
1. **The fork above.** B, phased. Record it here if it changes.
|
||||||
|
2. **Schema number. Checked, and it collided: 23, 24 and 25 are all taken on
|
||||||
|
`claude/focused-brattain-433b3b`**, which is unmerged — it keys a `DkgSession`
|
||||||
|
by profile (23), splits `ChatRoomMembership` off `ChatRoom` (24) and replaces
|
||||||
|
`ChatMessage.isUserMessage` with `senderIdentity` (25). `mantra` is at 22.
|
||||||
|
|
||||||
|
**Taken anyway: 23**, with `AutoMigration(from = 22, to = 23)`, because the
|
||||||
|
merge base is what a number is free *relative to* and pre-allocating 26 does
|
||||||
|
not avoid the work. Room needs a continuous path from the version on a user's
|
||||||
|
device: if that branch lands first the edge here has to become 25 → 26
|
||||||
|
regardless, and if it is itself renumbered or abandoned a pre-allocated 26
|
||||||
|
leaves three dead numbers behind. Whichever tree merges second renumbers, one
|
||||||
|
commit, which is the bargain `MantraDatabase`'s v22 comment already records.
|
||||||
|
|
||||||
|
The check is worth doing even though it did not change the answer, which is
|
||||||
|
the point `docs/project-feature.md` was making: it went wrong by checking the
|
||||||
|
wrong tree, not by failing to check. The sweep that found this:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for b in $(git for-each-ref --format='%(refname:short)' refs/heads/); do
|
||||||
|
git show "$b:composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt" 2>/dev/null |
|
||||||
|
grep -m1 "^ version = " | sed "s|$| <- $b|"
|
||||||
|
done | sort -u
|
||||||
|
```
|
||||||
|
3. **The dialect entry point. Settled: a dialect gets its own screen**, which is
|
||||||
|
Phase 2. The first draft of this plan recommended hanging the action off the
|
||||||
|
dialect card in `ChatRoomDetailScreen` instead, on the grounds that a whole
|
||||||
|
screen to hold one button is a screen somebody has to keep. That was wrong on
|
||||||
|
its own terms twice over, and the reversal is recorded rather than quietly
|
||||||
|
edited out because the argument against it is the argument for Phase 7.
|
||||||
|
|
||||||
|
A dialect is the one of the four with no screen, so the card would have been
|
||||||
|
the only entry point that *cannot grow*: Phase 7 gives every entity a list of
|
||||||
|
the subgroups made for it and the ceremonies in flight for it, and a
|
||||||
|
`ListItem` in a `LazyColumn` of dialects has nowhere to put either. The
|
||||||
|
asymmetry would land exactly where it hurts, since a dialect is the entity a
|
||||||
|
room is most likely to want several rooms for. And the card is already the
|
||||||
|
thinnest surface in that screen — it is the only one of the three lists whose
|
||||||
|
`Card` has no `onClick` at all, so a dialect is the one thing in a room a
|
||||||
|
member cannot open and look at.
|
||||||
|
4. **Whether a second subgroup for one entity is refused.** **Recommended: no.**
|
||||||
|
Two teams working on one artifact in two rooms is a real thing and refusing it
|
||||||
|
would be this device deciding a group's structure. What Phase 7 does instead is
|
||||||
|
show what already exists, which is what prevents the *accidental* second one.
|
||||||
|
|
||||||
|
## Phase 1 — `SubgroupOrigin`, and the trait that feeds it
|
||||||
|
|
||||||
|
One commit. Pure, `commonMain`, no database and no Compose.
|
||||||
|
|
||||||
|
A trait in `database/model/traits/`, implemented by the four entities:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
/**
|
||||||
|
* An entity a subgroup can be made for.
|
||||||
|
*
|
||||||
|
* Four rows in three tables have the same three fields and a per-type kind, and
|
||||||
|
* this is what makes "which things can a subgroup be made for" a list the
|
||||||
|
* compiler holds rather than four overloads somebody has to remember to extend.
|
||||||
|
*/
|
||||||
|
interface SubgroupOriginEntity {
|
||||||
|
val id: HexKey
|
||||||
|
val name: String
|
||||||
|
val chatRoomId: String
|
||||||
|
val subgroupOriginKind: Kind
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`MantraDialect`, `MantraArtifact`, `MantraTranslationArtifactVersion` and
|
||||||
|
`MantraProject` each gain `override` on the three fields they already have and
|
||||||
|
one line for the kind. **No `toXEvent` or tag order changes** — the trait is
|
||||||
|
about naming a room, touches no event, and so cannot move an event id.
|
||||||
|
|
||||||
|
Then the function the whole plan is named after:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
object SubgroupOrigin {
|
||||||
|
/** What a certificate names: the kind and the id, together or not at all. */
|
||||||
|
data class Ref(val kind: Kind, val id: HexKey)
|
||||||
|
|
||||||
|
/** Everything a picker needs to open a subgroup for [entity]. */
|
||||||
|
data class Candidate(
|
||||||
|
val parentChatRoomId: String,
|
||||||
|
val name: String,
|
||||||
|
val origin: Ref,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun of(entity: SubgroupOriginEntity): Candidate
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Parallel to `TranslationScaffold` and `ChapterScaffolding`: `managers/`, pure,
|
||||||
|
tested in `commonTest`. `name` is trimmed and handed over bare — the `#` is added
|
||||||
|
by `MarmotGroupName.of` where the name is used, twice, idempotently, exactly as
|
||||||
|
the typed name is today.
|
||||||
|
|
||||||
|
Tests (`commonTest`, `SubgroupOriginTest`):
|
||||||
|
|
||||||
|
- one case per entity type, asserting parent, name and ref;
|
||||||
|
- a blank-named entity yields a blank candidate name rather than inventing one,
|
||||||
|
so the picker's "Give the subgroup a name." refusal still fires;
|
||||||
|
- `Ref` holds a kind and an id and there is no constructor that holds one.
|
||||||
|
|
||||||
|
## Phase 2 — a dialect gets a screen
|
||||||
|
|
||||||
|
One commit. No new machinery, two new queries, and the only phase here that is
|
||||||
|
not about subgroups at all.
|
||||||
|
|
||||||
|
Artifacts, translations and projects each have a route, a UI state, a view model
|
||||||
|
and a screen. A dialect has a `ListItem` inside `ChatRoomDetailScreen`'s
|
||||||
|
`LazyColumn` with no `onClick`, so it is the one entity in a room that cannot be
|
||||||
|
opened. Phase 3 needs somewhere to put an action and Phase 7 needs somewhere to
|
||||||
|
put two lists, and this is that place.
|
||||||
|
|
||||||
|
Follow `ArtifactDetailViewModel` exactly — it is the closest sibling and the
|
||||||
|
shortest, which is the point of copying it rather than the richer ones:
|
||||||
|
|
||||||
|
| piece | shape |
|
||||||
|
|---|---|
|
||||||
|
| `DialectDetailRoute` | `activeUserPublicKey`, `dialectId`, `chatRoomId`, `relayHint` |
|
||||||
|
| `DialectDetailUIState` | sealed `Loading` / `Error` / `Loaded` |
|
||||||
|
| `DialectDetailViewModel` | `initiateDialectDetail()`, a `factory`, `MantraRepository` |
|
||||||
|
| `DialectDetailScreen` | registered in `MantraNavHost` beside `ArtifactDetailRoute` |
|
||||||
|
|
||||||
|
`Loaded` carries the dialect, what is written *in* it, and the capability flag:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
data class Loaded(
|
||||||
|
val dialect: MantraDialect,
|
||||||
|
val artifacts: List<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.
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user