Merge branch 'mantra' into claude/focused-brattain-433b3b
Takes the dialect catalogue, the subgroups-for-entities line and the first four ios phases -- thirty-two commits. **Renumbered again, and this time the collision was not only a number.** `mantra` reached 23 with two nullable `DkgSession` columns saying what a subgroup is *for*. That is the table this branch's first migration rebuilds. So the three become 23->24, 24->25 and 25->26, and the rebuild's column list grew by `originKind` and `originId` -- a rebuild is the one kind of migration that cannot ignore a column it knows nothing about, because leaving one out drops it. **And the rebuild had a bug that only this merge could expose.** It copied with `SELECT *`, which was fine while every file reaching it had the entity's declared column order. An `AutoMigration` that adds a column does it with `ALTER TABLE ... ADD COLUMN`, appending it physically at the end -- so a file that came through mantra's 22->23 hands `SELECT *` the columns in a different order than the backup table declares them, and the copy lands shifted. `DkgSessionMigrationThroughRoomJvmTest` and `ChatRoomSplitThroughRoomJvmTest` both failed with `NOT NULL constraint failed: DkgSessionBackup.updatedAt`, which is `originKind` arriving where a timestamp was expected. Both copies name their columns now. Schemas 24 and 25 were regenerated, since each describes one of this branch's migrations applied to a base that has moved. 26 is the current shape. The chain checks out: 23 adds the origin columns, 24 keys the ritual by profile and carries them, 25 splits the membership off the room, 26 puts the sender's identity on a transcript line. Four new reads of a room's MLS state came with the dialect and project detail screens, and ask through the viewing profile like the rest. 1298 tests, 0 failures.
This commit is contained in:
commit
c0db6908a1
135 files changed
+14109
-299
No files matched your search
@@ -10,6 +10,7 @@ silent, or a decision that looked arbitrary and was not.
|
||||
| [shared-key-derivation.md](./shared-key-derivation.md) | deriving further keys from the group's threshold key with FROST tweaks — why not BIP32, why no chain code, and the one rule that must not be broken |
|
||||
| [subgroups.md](./subgroups.md) | a group making another group — the four ceremonies, what the parent's signature actually covers, and why the child's key is fresh rather than derived |
|
||||
| [subgroups-multiple-parents.md](./subgroups-multiple-parents.md) | adding a parent to a group that already exists — two signings and no ceremony, why the child's consent is the whole gate, and the certificate a group can truthfully sign about itself |
|
||||
| [subgroups-for-entities.md](./subgroups-for-entities.md) | giving a dialect, an artifact, a translation or a project a room of its own — why the link belongs on the birth certificate rather than in a column, and the screen a dialect needed before it could be asked |
|
||||
| [frost-batch-signing.md](./frost-batch-signing.md) | signing several events in one ceremony — why one nonce can never cover two messages, and the phased schema, wire and UI work that follows from it |
|
||||
| [marmot-membership.md](./marmot-membership.md) | how members join an MLS group, and the epoch race that makes a missing member look like a successful invite |
|
||||
| [member-chronicle.md](./member-chronicle.md) | handing a member added after the work was done the group's signed record — why the events are not on the wire at all, and why the room's id is enough to verify them |
|
||||
@@ -21,8 +22,10 @@ silent, or a decision that looked arbitrary and was not.
|
||||
| [npub-sign-in.md](./npub-sign-in.md) | signing in with only a public key — what a key that cannot sign can still see here, why that is a preview rather than a browser, and the four decisions the word settles |
|
||||
| [multiple-profiles.md](./multiple-profiles.md) | several profiles on one device — why a profile is a credential and a seed is a wallet attached to one, why a switch is a restart rather than a swap, the two entrances the app lacks, and the inbox a switch would silently lose |
|
||||
| [jvm-target.md](./jvm-target.md) | what desktop support cost, phased — why the native chain was already done, why an empty source set in our phoenix fork was the real blocker, and why DAO tests need none of it |
|
||||
| [ios-build.md](./ios-build.md) | building for an iPhone, following the route the Curated fork walked — the five defects between here and a device, why the fork's "linux cinterop" fix is the one that makes a mac work, and the one annotation that unblocks native by changing every other target |
|
||||
| [npub-profile-preview.md](./npub-profile-preview.md) | showing the person before a direct message is started from a pasted npub — why the preview is a screen keyed by public key, what "found" means when a row can be a placeholder, and the phase that makes the button honest about a key package that never comes |
|
||||
| [chapter-uploads.md](./chapter-uploads.md) | picking files off a device and having the group sign each one in as a chapter — why N files can never be one signing session, and the four things the single-chapter path gets away with |
|
||||
| [african-dialect-catalogue.md](./african-dialect-catalogue.md) | filling a group's dialects from a bundled catalogue of the languages most spoken in Africa — why a dialect is a language *in a country*, why nothing in the schema makes one unique so duplication has to be stopped at proposal time, and the race two admins hit if the picker cannot see what is already being signed |
|
||||
| [project-feature.md](./project-feature.md) | bringing the server schema's `project` and its two join models into a group that has no accounts and no server — a creator tag of its own and owners as a signed set of `p` tags, the role the parser has to be told about or every project lands ownerless, why a membership is its own signed event rather than a list, and the kind band that is already double-booked |
|
||||
| [material-design-conformance.md](./material-design-conformance.md) | what the M3 foundations actually require, measured against all 43 screens — the colour pairing that renders the app's own proposals invisible, and eight phases that put the decisions back in the theme |
|
||||
| [curated-to-mantra.md](./curated-to-mantra.md) | pulling the Curated fork's thirty-nine commits back under Mantra's names — which lines of work to take, the three decisions, and a measured way to replay a twice-rebranded history without touching seven hundred files by hand |
|
||||
@@ -88,6 +91,17 @@ built, because each of those is wrong in the single-chapter path today. Its two
|
||||
splitter phases are separable from the rest and worth reading on their own: what
|
||||
a chunk is, measured against the parser the web app translates with rather than
|
||||
assumed.
|
||||
The African dialect catalogue note is a phased plan that has been built. It is
|
||||
the smallest of the nip30303 plans and the only one whose hard part is an
|
||||
identity rule rather than a ceremony: a dialect's id is a content hash including
|
||||
`createdAt`, there is no unique index, and the rows are quorum-signed, so two
|
||||
proposals of the same dialect are two rows and neither is wrong by any rule the
|
||||
schema knows. Read its two framing sections before its phases -- they are what
|
||||
every phase is built to keep -- and read the batch-signing note first if the
|
||||
all-or-nothing rule is not already familiar. Its last phase is a fix the feature
|
||||
makes necessary rather than part of it, which is worth knowing before reading the
|
||||
diff.
|
||||
|
||||
The projects note is a phased plan that has been built as far as its seventh
|
||||
phase, and is the only one
|
||||
whose starting point is outside this repository: the platform's server schema,
|
||||
|
||||
@@ -0,0 +1,535 @@
|
||||
# A catalogue of African languages, and dialects proposed in waves
|
||||
|
||||
How a group fills its dialect list from a bundled catalogue of the languages most
|
||||
spoken in Africa instead of typing each one into a three-field form, and how
|
||||
admins top that list up later — proposing only what is still missing, in waves,
|
||||
without two of them proposing the same wave twice.
|
||||
|
||||
**Built.** All seven phases are in, one commit each, and the phases below are
|
||||
kept as written -- they are the reasoning, and the code is easier to read against
|
||||
the argument it came from than against a summary of itself. Where the
|
||||
implementation chose differently the table at the end says so.
|
||||
|
||||
`AfricanLanguages.ALL` is the catalogue, `DialectScaffold` answers what a room is
|
||||
missing, and `AddDialectsFromCatalogueScreen` is the picker, reached from the
|
||||
group's detail screen beside "Add dialect".
|
||||
|
||||
Two facts shape every phase and are stated before them, because both are easy to
|
||||
design around and expensive to discover afterwards.
|
||||
|
||||
## A dialect is a language *in a place*
|
||||
|
||||
`MantraDialect` is `(name, country, language)` — "Sesotho", "Lesotho", `st`. The
|
||||
`language` column holds an ISO 639 code; the form's placeholder has always said
|
||||
so (`eg. st`). So the catalogue's unit is not a language but a language paired
|
||||
with a country, and Swahili-in-Kenya and Swahili-in-Tanzania are two entries.
|
||||
That is the feature rather than a duplication: a group translating for Kenyan
|
||||
readers is not translating for Tanzanian ones, and the whole reason this kind is
|
||||
called a dialect is that it is finer than a language.
|
||||
|
||||
## Nothing anywhere makes a dialect unique
|
||||
|
||||
A dialect's id is `EventHasher.hashId(pubKey, tags, content, createdAt, kind)`
|
||||
over the room's key. Two proposals of Sesotho-in-Lesotho a minute apart hash
|
||||
differently, are two `MantraDialect` rows, and neither is wrong by any rule the
|
||||
schema knows. There is no unique index, and adding one would reject a legitimate
|
||||
re-signing as readily as a duplicate.
|
||||
|
||||
So **duplication is prevented at proposal time or not at all**, and the handle
|
||||
has to be `(code, country)`. Not the name: a member typing "Sesotho" and a
|
||||
catalogue saying "Sesotho" agree today and disagree the moment one of them
|
||||
carries a diacritic, and the name is the one field of the three that is a matter
|
||||
of spelling rather than of fact.
|
||||
|
||||
## A session signs at most 64 events, all or nothing
|
||||
|
||||
`FrostSigningManager.MAX_BATCH_SIZE` is 64 and `MAX_PROPOSAL_BYTES` is 64 KiB,
|
||||
and a batch that cannot aggregate one item applies none of them —
|
||||
[frost-batch-signing.md](frost-batch-signing.md) says why, and says a retry must
|
||||
be a fresh batch rather than the same one again.
|
||||
|
||||
The catalogue below is about fifty entries, so a first wave is one session. Both
|
||||
properties still have to be defended by a test, because a catalogue that grows
|
||||
to sixty-five silently becomes two sessions and a reviewer adding an entry has no
|
||||
reason to be thinking about FROST.
|
||||
|
||||
The all-or-nothing risk is answered by the same rule that prevents duplication:
|
||||
because the picker proposes only what is missing, re-proposing a failed wave is
|
||||
safe and lands exactly the entries the failed one did not. "It is safe to ask
|
||||
again" is what the signing screen already tells a member of a failed session, and
|
||||
here it is true without qualification.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — the catalogue, as data
|
||||
|
||||
**Half a day. No behaviour change; nothing reads it yet.**
|
||||
|
||||
New file,
|
||||
`composeApp/src/commonMain/kotlin/press/mantra/compose/language/AfricanLanguage.kt`:
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* A language the catalogue offers, in one of the countries it is spoken in.
|
||||
*
|
||||
* The unit is the pair, not the language: a dialect is `(name, country, code)`,
|
||||
* so Swahili appears once for Kenya and once for Tanzania. See the note on
|
||||
* identity in docs/african-dialect-catalogue.md.
|
||||
*/
|
||||
data class AfricanLanguage(
|
||||
/** What the dialect row will be called. The endonym where there is a settled one. */
|
||||
val name: String,
|
||||
/** ISO 639-1 where the language has one, ISO 639-3 otherwise. */
|
||||
val code: String,
|
||||
val country: String,
|
||||
)
|
||||
|
||||
object AfricanLanguages {
|
||||
/** Ordered by country, so a reader scanning for theirs scans once. */
|
||||
val ALL: List<AfricanLanguage> = listOf(/* … */)
|
||||
}
|
||||
```
|
||||
|
||||
**Where it lives matters.** `press/mantra/compose/language/` rather than
|
||||
anywhere under `ui/`, because `docs/scripts/m3-audit.sh` scans `ui/` and
|
||||
`strings.xml` for string literals and title case, and "Northern Sotho" is title
|
||||
case for exactly the reason `m3-title-case.py` keeps a `SAMPLE` set of proper
|
||||
nouns at all — it is one. Putting reference data outside `ui/` is cheaper and more honest than an
|
||||
`m3-string-exempt` marker on a file that is not UI at all.
|
||||
|
||||
It is also not a composeResource. There is no `files/` directory under
|
||||
`composeResources` today, a bundled JSON would need a parser and a failure mode,
|
||||
and a Kotlin list is compile-checked, greppable and testable. The catalogue is
|
||||
reference data in the same sense `SMALL` in `m3-title-case.py` is.
|
||||
|
||||
**Codes: 639-1 where it exists, 639-3 otherwise.** `st`, `sw`, `zu`, `ha` — and
|
||||
`nso`, `bem`, `luo`, `mos`, `kab` for the ones ISO never gave two letters. The
|
||||
alternative, 639-3 throughout, is more uniform and worse here: it would make the
|
||||
catalogue's `sot` disagree with the `st` every room that used the form has
|
||||
already signed, and those rows cannot be rewritten — they are signed by a
|
||||
quorum.
|
||||
|
||||
Roughly fifty entries, covering the cross-border lingua francas and the
|
||||
high-population national languages: Swahili (Tanzania, Kenya), Hausa (Nigeria,
|
||||
Niger), Yoruba, Igbo, Fulfulde, Amharic, Oromo, Tigrinya, Somali, Arabic (Egypt,
|
||||
Sudan, Morocco), Zulu, Xhosa, Afrikaans, Northern Sotho, Southern Sotho, Tswana,
|
||||
Tsonga, Venda, siSwati, Shona, Northern Ndebele, Chichewa, Bemba, Lingala,
|
||||
Kikongo, Tshiluba, Kinyarwanda, Kirundi, Luganda, Kikuyu, Dholuo, Akan, Ewe, Ga,
|
||||
Wolof, Bambara, Mooré, Malagasy, Sango, Umbundu, Kimbundu, Makhuwa, Tamazight,
|
||||
Kabyle, Krio.
|
||||
|
||||
**One judgment call to settle in review.** French, Portuguese and English are
|
||||
among the most spoken languages on the continent and are not African languages.
|
||||
Including them makes the catalogue useful to groups that actually translate into
|
||||
them; leaving them out keeps it a catalogue of African languages. The plan
|
||||
includes three entries — `fr` · DR Congo, `pt` · Angola, `en` · Nigeria — as one
|
||||
contiguous, clearly commented block, so deleting them is a four-line change
|
||||
rather than an argument.
|
||||
|
||||
**Test:** `composeApp/src/commonTest/.../language/AfricanLanguagesTest.kt` —
|
||||
every code lowercase and two or three characters, every name and country
|
||||
non-blank, `(code, country)` unique across the list, and the list ordered as it
|
||||
claims to be. The uniqueness assertion is the one that earns its place: a
|
||||
duplicate pair in the catalogue would propose the same dialect twice in one wave,
|
||||
and the two would not even collide, because their ids differ.
|
||||
|
||||
**Acceptance:** `:composeApp:compileDebugKotlinAndroid` and the new test pass;
|
||||
`:composeApp:m3Audit` unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — `DialectScaffold`: what is missing, and the events for it
|
||||
|
||||
**Half a day. Pure functions. Still nothing calls them.**
|
||||
|
||||
New file,
|
||||
`composeApp/src/commonMain/kotlin/press/mantra/compose/managers/DialectScaffold.kt`,
|
||||
built as a deliberate parallel to
|
||||
[`TranslationScaffold`](../composeApp/src/commonMain/kotlin/press/mantra/compose/managers/TranslationScaffold.kt) —
|
||||
same package, same shape, same reasons:
|
||||
|
||||
```kotlin
|
||||
object DialectScaffold {
|
||||
/** The catalogue entries this room has no dialect for, and none in flight. */
|
||||
fun missingFrom(
|
||||
catalogue: List<AfricanLanguage> = AfricanLanguages.ALL,
|
||||
existing: List<MantraDialect>,
|
||||
pending: List<AfricanLanguage> = emptyList(),
|
||||
): List<AfricanLanguage>
|
||||
|
||||
/** One dialect event per entry, stamped with one timestamp for the wave. */
|
||||
fun dialectsOf(
|
||||
languages: List<AfricanLanguage>,
|
||||
createdAt: Long,
|
||||
): List<EventTemplate<DialectEvent>>
|
||||
}
|
||||
```
|
||||
|
||||
`missingFrom` matches on `(code, country)` normalised with `trim()` and
|
||||
`lowercase()` — the no-argument `lowercase()` deliberately, which in Kotlin
|
||||
common is locale-invariant, so a Turkish device does not decide that `TI` and
|
||||
`ti` are different languages. The name is ignored for matching and carried
|
||||
through for display.
|
||||
|
||||
`dialectsOf` takes one `createdAt` for the whole list rather than reading the
|
||||
clock per template, for the reason `TranslationScaffold.chaptersOf` does: a wave
|
||||
proposed as one act should read as one act, not as fifty dialects that happen to
|
||||
share a minute. It also makes the wave's event ids a function of its inputs,
|
||||
which is what makes the test below possible.
|
||||
|
||||
**Test:** `composeApp/src/commonTest/.../managers/DialectScaffoldTest.kt`,
|
||||
mirroring `TranslationScaffoldTest`:
|
||||
|
||||
- a room with no dialects is missing the whole catalogue;
|
||||
- a room holding `st` · Lesotho is missing everything else and not that;
|
||||
- a row whose code is ` ST ` still counts as held;
|
||||
- a row whose *name* is spelled differently but whose code and country match
|
||||
still counts as held — the assertion that pins the identity rule;
|
||||
- `sw` · Kenya held does not make `sw` · Tanzania held;
|
||||
- a row with a code the catalogue does not list changes nothing;
|
||||
- `dialectsOf` emits `DialectEvent.KIND`, in catalogue order, all with the one
|
||||
`createdAt`, and each round-trips through `MantraDialect.fromDialectEventTemplate`
|
||||
back to the name, country and code it came from.
|
||||
|
||||
**Acceptance:** compile and the new test pass.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — what is already in flight
|
||||
|
||||
**Half a day. Read-only; the first phase that touches a repository.**
|
||||
|
||||
A catalogue entry is in one of three states, and the picker is wrong without all
|
||||
three:
|
||||
|
||||
| state | how it is known | selectable |
|
||||
|---|---|---|
|
||||
| in the room | a `MantraDialect` row for `(code, country)` | no |
|
||||
| in flight | an open signing session holds a kind-30304 event for `(code, country)` | no |
|
||||
| missing | neither | yes |
|
||||
|
||||
Without "in flight" the feature has a race it will hit on its first real use:
|
||||
two admins open the picker, both see the same forty missing, both propose, and
|
||||
the room gets eighty dialects — forty of them duplicates that nothing will ever
|
||||
merge, because nothing makes a dialect unique.
|
||||
|
||||
Everything needed is already on `FrostSigningRepository`:
|
||||
`observeSessionsForChatRoom(chatRoomId)` gives `LocalFrostSigningSession`, each
|
||||
with its `items`, and `unsignedEvents(items)` turns those into events **at
|
||||
proposal time** — before any signature, which is precisely when this has to be
|
||||
readable.
|
||||
|
||||
Add one more pure function to `DialectScaffold`:
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* The catalogue entries an open session is already signing.
|
||||
*
|
||||
* `FrostSigningStage.FAILED` and `COMPLETE` are both excluded, for opposite
|
||||
* reasons: a failed wave left nothing behind and must be proposable again --
|
||||
* the signing screen says as much -- and a complete one is a row by now, so
|
||||
* counting it here would hide it behind the wrong explanation.
|
||||
*/
|
||||
fun pendingIn(
|
||||
sessions: List<LocalFrostSigningSession>,
|
||||
unsignedEvents: (List<FrostSigningItem>) -> List<Event>,
|
||||
catalogue: List<AfricanLanguage> = AfricanLanguages.ALL,
|
||||
): List<AfricanLanguage>
|
||||
```
|
||||
|
||||
The `unsignedEvents` lambda is passed in rather than imported, for the reason
|
||||
`ProposedEvent.summarize` takes `resolve`: it keeps the function pure and
|
||||
testable without a database behind it.
|
||||
|
||||
**Test:** `commonTest`, with hand-built `FrostSigningItem` rows holding
|
||||
`DialectEvent.build(...)` JSON. Cases: an open session's dialects are pending; a
|
||||
`FAILED` session's are not; a `COMPLETE` session's are not; a session signing
|
||||
chapters contributes nothing; an item whose JSON will not parse is skipped rather
|
||||
than throwing — a malformed item must not take the picker down with it.
|
||||
|
||||
**Acceptance:** compile and the new test pass. No wire change, no schema change.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — the picker
|
||||
|
||||
**Two days. The screen, and the way in.**
|
||||
|
||||
A new pushed screen beside the hand-typed form, which stays exactly as it is.
|
||||
Five files, following the house pattern (route · state · view model · screen ·
|
||||
registration) and `SelectSubgroupAdminsScreen` for the multi-select mechanics:
|
||||
|
||||
- `ui/composable/navigation/routes/AddDialectsFromCatalogueRoute.kt` —
|
||||
`activeUserPublicKey`, `chatRoomId`, `relayHint`, as `AddDialectRoute` has.
|
||||
- `ui/view/state/AddDialectsFromCatalogueUIState.kt` — `Loading` / `Error` /
|
||||
`Loaded(localChatRoom, rows: List<CatalogueRow>, selected: Set<String>, canSign)`,
|
||||
where a `CatalogueRow` is an `AfricanLanguage` plus its state from the table in
|
||||
phase 3.
|
||||
- `ui/view/model/AddDialectsFromCatalogueViewModel.kt`
|
||||
- `ui/composable/AddDialectsFromCatalogueScreen.kt`
|
||||
- the `composable<…>` arm in `MantraNavHost.kt`, copying `AddDialectRoute`'s
|
||||
`popUpTo` so back does not return to a form whose proposal has gone out.
|
||||
|
||||
**State goes in a `StateFlow`, not `mutableStateOf`.** The existing
|
||||
`AddDialectViewModel` uses `mutableStateOf` and gets away with it because it
|
||||
writes the state once. This screen does not: the loaded state is written from
|
||||
`Dispatchers.IO`, *and* a collector on `observeSessionsForChatRoom` folds the
|
||||
pending list into it, *and* the UI read-modify-writes the selection. That is the
|
||||
case where a `mutableStateOf` in a view model loses writes — a
|
||||
`MutableStateFlow` with `update { }` is the shape that does not.
|
||||
|
||||
The selection may live beside it as `mutableStateListOf<String>()` of
|
||||
`"$code|$country"` keys, which is what `SelectSubgroupAdminsViewModel` does with
|
||||
`selectedPublicKeys`; the point is that it is not folded into a `mutableStateOf`
|
||||
holding the whole UI state.
|
||||
|
||||
**What the screen shows.** A `TopAppBar` with `NavigateBackButton` (it is pushed;
|
||||
the desktop target has no system back), a search field filtering on name, code
|
||||
and country, and a `LazyColumn` of `ListItem` rows grouped by country. Each row
|
||||
by state:
|
||||
|
||||
- **missing** — a `Checkbox`, enabled, toggling selection;
|
||||
- **in the room** — a `Checkbox(checked = true, enabled = false, onCheckedChange = null)`
|
||||
and a supporting line saying so, exactly as the subgroup screen renders the
|
||||
coordinator who is an admin by construction;
|
||||
- **in flight** — disabled, `Modifier.semantics { disabled() }`, supporting line
|
||||
"waiting for the group to sign", with the pending wave reachable from the
|
||||
room's proposal list.
|
||||
|
||||
A header line carries the count — "12 of 50 selected" — and a "select everything
|
||||
missing" action, which is the whole convenience the feature is named for.
|
||||
|
||||
All four screen states, per the house rules: `ScreenStateTransition` wrapping the
|
||||
`when`, `ErrorState` with a real `onRetry` (re-running `initiate()` is a genuine
|
||||
retry here, so passing null would be the wrong decision), `LoadingDataIndicator`,
|
||||
and an `EmptyState` with its own message for the case worth getting right — a
|
||||
room that already holds the whole catalogue. "No dialects found" would be a lie
|
||||
there; the message is that the catalogue has nothing left to add and the form is
|
||||
the way to add one it does not list, with that form as the `EmptyState`'s
|
||||
`action`.
|
||||
|
||||
`canSign` gates the FAB the way `AddDialectScreen` does, with the same
|
||||
explanation of why, because a group with no shared key cannot sign a dialect into
|
||||
existence at all.
|
||||
|
||||
**Spacing from `MaterialTheme.spacing`, colour from roles, strings from
|
||||
`strings.xml` in sentence case, every target 48dp, every icon's
|
||||
`contentDescription` decided, `Modifier.padding(innerPadding).readableContent()`
|
||||
on the content root.** About a dozen new strings. The language names are *not*
|
||||
among them: they come from the catalogue, which is data.
|
||||
|
||||
**The way in:** a second `TextButton` under the dialect list on
|
||||
`ChatRoomDetailScreen`, beside "Add dialect" — "Add dialects from a list". The
|
||||
existing button is untouched.
|
||||
|
||||
**Test:** a `@ConformancePreviews` preview per state, as every screen here has,
|
||||
with `FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY`. Row-state logic is
|
||||
phase 3's and is tested there rather than through the composable.
|
||||
|
||||
**Acceptance:** `:composeApp:m3Audit` reports no new budget breach and no drop
|
||||
below either floor; `compileDebugKotlinAndroid` and `jvmTest` pass; the previews
|
||||
render in all six schemes.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — proposing the wave
|
||||
|
||||
**A day. The first phase that changes what a group sees.**
|
||||
|
||||
One method on the view model, built on
|
||||
`TranslationArtifactVersionDetailViewModel.catchUpMissingChapters` — which is the
|
||||
same problem, down to the chunking:
|
||||
|
||||
```kotlin
|
||||
fun proposeSelectedDialects(
|
||||
localChatRoom: LocalChatRoom,
|
||||
selected: List<AfricanLanguage>,
|
||||
onSuccess: (sessionId: String) -> Unit,
|
||||
onFailure: () -> Unit,
|
||||
)
|
||||
```
|
||||
|
||||
- empty selection fails fast rather than opening an empty session;
|
||||
- `isActionPending` guards the double tap, as every proposing view model here
|
||||
does;
|
||||
- one `TimeUtils.now()` for the wave;
|
||||
- `selected.chunked(FrostSigningManager.MAX_BATCH_SIZE)`, one
|
||||
`proposeSigningBatch(localChatRoom, userPublicKey, events)` per chunk — the
|
||||
flat form, since dialects name nothing and need no lead;
|
||||
- navigate to the **first** session with `popUpTo<AddDialectsFromCatalogueRoute> { inclusive = true }`,
|
||||
the rest being in the room's proposal list beside it;
|
||||
- the `onSuccess` callback invoked from `Dispatchers.Main`. This is not
|
||||
decoration: the callback navigates, and a navigation from `Dispatchers.IO`
|
||||
crashes. The handoff belongs inside the view model, which is where the
|
||||
existing ones put it.
|
||||
|
||||
With fifty entries the chunking never fires. It is one line, it is the
|
||||
established idiom, and it is what stops the sixty-fifth catalogue entry from
|
||||
being a bug instead of a second session.
|
||||
|
||||
Nothing else needs changing to make a wave readable.
|
||||
`ProposedEvent.summarize` already has its `DialectEvent.KIND` arm — "New dialect"
|
||||
over "Sesotho · Lesotho · st" — and `WhatIsBeingSigned` already leads a batch
|
||||
with "N events signed together" and renders every item, refusing to let any of it
|
||||
be signed unless all of it parsed. Fifty two-line blocks in a scrolling `Column`
|
||||
is a long screen, and that is the correct length: a member is being asked to put
|
||||
the group's name to fifty dialects, and the batch is all-or-nothing.
|
||||
|
||||
**Test:** `composeApp/src/jvmTest/.../managers/DialectWaveProposalJvmTest.kt`,
|
||||
modelled on `TranslationBatchProposalJvmTest` — over a real in-memory database
|
||||
and a real key, assert that a wave of N opens one session with N items in
|
||||
catalogue order, that each item's unsigned event is a kind-30304 whose tags round
|
||||
-trip to the catalogue entry, that the events share one `createdAt`, and that a
|
||||
selection of 65 opens two sessions with 64 and 1.
|
||||
|
||||
**Acceptance:** the above, plus a manual pass: pick five, propose, approve on
|
||||
enough devices, see five dialect rows appear on the group detail screen.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — one transcript line for a wave, not fifty
|
||||
|
||||
**Half a day. A behaviour fix the previous phase makes necessary.**
|
||||
|
||||
`FrostSigningManager.applySignedEvent` hands each signed event to
|
||||
`ChatMessage.applyInnerEvent` and files whatever chat line comes back. The
|
||||
dialect arm returns one — "Added Sesotho to dialects" — so a wave of fifty posts
|
||||
fifty lines into the group's conversation, followed by the session's own "The
|
||||
group signed 50 events".
|
||||
|
||||
This is not a problem the existing batches have, and the reason is instructive.
|
||||
Of the kinds `applyInnerEvent` files a line for — chat, artifact, chapter,
|
||||
dialect, translation, project — every one that can appear many-per-batch already
|
||||
returns null for exactly this reason: a chunk is part of a chapter and the
|
||||
chapter's line speaks for it; a translation chapter, a contributor list and both
|
||||
project memberships likewise. `DialectEvent` is the first kind where a batch
|
||||
holds many peers each of which wants a line of its own.
|
||||
|
||||
So the rule, stated once where the batch is visible:
|
||||
|
||||
> A signed event files its chat line unless the batch holds more than one event
|
||||
> of its kind, in which case the session's completion line speaks for them.
|
||||
|
||||
Implemented as a `fileChatLine: Boolean` parameter on the private
|
||||
`applySignedEvent`, computed in its one caller as
|
||||
`signedEvents.count { it.kind == signedEvent.kind } == 1`. `applyInnerEvent` is
|
||||
untouched — it still writes the `MantraDialect` row, which is the part that
|
||||
matters — and only the `chatMessageDao().upsert` is skipped.
|
||||
|
||||
Checked against every batch the app builds today, this changes nothing: a chapter
|
||||
batch is one `ChapterEvent` and N `ChunkEvent`s that file nothing; a translation
|
||||
batch is one `TranslationArtifactVersionEvent` and N translation chapters that
|
||||
file nothing; a project batch is one `ProjectEvent` and N memberships that file
|
||||
nothing. The only batch it affects is the one phase 5 introduces.
|
||||
|
||||
`ChronicleManager.applyPage` needs nothing: it already drops the chat line a
|
||||
payload returns, and says why.
|
||||
|
||||
**Test:** extend the jvmTest that covers `applySignedEvent`'s effects — a batch
|
||||
of one dialect still files its line; a batch of three files none and still writes
|
||||
three `MantraDialect` rows; a batch of a chapter and its chunks still files the
|
||||
chapter's line.
|
||||
|
||||
**Acceptance:** the above, and a manual pass showing one completion line rather
|
||||
than fifty.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — the guards that keep it true
|
||||
|
||||
**Half a day.**
|
||||
|
||||
`composeApp/src/commonTest/.../language/DialectCatalogueSizeTest.kt`, modelled on
|
||||
[`ChapterProposalSizeTest`](../composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ChapterProposalSizeTest.kt):
|
||||
|
||||
- the whole catalogue fits one batch —
|
||||
`AfricanLanguages.ALL.size <= FrostSigningManager.MAX_BATCH_SIZE`;
|
||||
- the whole catalogue as a proposal fits `MAX_PROPOSAL_BYTES`, measured the way
|
||||
the manager measures it rather than estimated;
|
||||
- and, because the first assertion will eventually fail on purpose, a failure
|
||||
message that says what to do about it: the chunking in phase 5 already handles
|
||||
it, and the test is lowered to a note rather than the catalogue trimmed.
|
||||
|
||||
Then the two commands from `CLAUDE.md`, and a line in
|
||||
[docs/README.md](README.md) pointing here.
|
||||
|
||||
```bash
|
||||
./gradlew :composeApp:m3Audit
|
||||
```
|
||||
|
||||
```bash
|
||||
ANDROID_HOME=/home/sigidli/Android/Sdk ./gradlew :composeApp:compileDebugKotlinAndroid :composeApp:jvmTest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What this deliberately does not do
|
||||
|
||||
**No schema change, no migration, no new event kind, no wire change.** The
|
||||
feature is a different way of building events the group already signs. Database
|
||||
version stays at 22.
|
||||
|
||||
**No uniqueness constraint on `MantraDialect`.** It would reject a re-signing as
|
||||
readily as a duplicate, and the duplicate is prevented at proposal time instead —
|
||||
see the note at the top.
|
||||
|
||||
**No merging of dialects that are already duplicated.** A room that typed
|
||||
"Sesotho" by hand and then selects it from the catalogue gets one row, because
|
||||
the codes match. A room holding two rows from before this feature keeps both;
|
||||
nothing here can remove a row a quorum signed.
|
||||
|
||||
**No admin role.** The user's framing was that admins propose in waves, and in
|
||||
this app the gate on proposing is `canSign` — holding a share of the group's key
|
||||
— rather than a role. Every member who can sign can propose a wave, and a quorum
|
||||
decides. Introducing a dialect-proposing role would be a change to what a group
|
||||
is, not a convenience method.
|
||||
|
||||
**No automatic wave.** Nothing proposes the catalogue on a room's behalf when it
|
||||
is created. A dialect is a statement the group signs about what it will
|
||||
translate into, and fifty of them arriving unasked is a statement nobody made.
|
||||
|
||||
---
|
||||
|
||||
## Where the build chose differently
|
||||
|
||||
| the plan said | the build does | why |
|
||||
|---|---|---|
|
||||
| "about fifty entries" | 56 — 53 African, 3 European | the fan-out across countries landed there. 8 under `MAX_BATCH_SIZE`, which is thinner than it reads; phase 7's test is the tripwire |
|
||||
| one `african` list | two, `african` and `european`, with `ALL` their sum | the ordering test needs a list that is actually grouped by country, and appending the three European entries breaks that grouping. It also makes "drop them" one edit, which is what the plan promised |
|
||||
| `missingFrom(existing, pending, catalogue)` | that, plus a public `DialectScaffold.Key` and three `keyOf` overloads | the identity rule has four callers — the scaffold, the row classifier, the selection and the view model's unpick — and spelling `(code, country)` four times is four chances to forget `trim()` |
|
||||
| phase 4 ships the picker with its FAB | phase 4 shows and picks, phase 5 proposes | a commit whose FAB calls a method written in the next commit does not compile. Split so each commit does something whole |
|
||||
| `proposeSelectedDialects(localChatRoom, selected, …)` | the selection is read internally | the view model owns `selected`; passing it back in lets a caller pass something else, and nothing needs to |
|
||||
| `CatalogueRow.rowsOf` computes the three standings | it derives them from `missingFrom` | two functions deciding what is proposable is one too many. MISSING is whatever that function says, and held-versus-in-flight is a label on what it already excluded |
|
||||
| a wave "is small rather than just legal" — under a quarter of `MAX_PROPOSAL_BYTES` | at most 602 bytes *per entry* | the catalogue encodes to 16,832 bytes, which is 25.7% of the budget — the quarter was a guess and it failed. The per-entry cost is the thing that would actually drift, and it does not move when a language is added |
|
||||
|
||||
### One thing found on the way, and fixed
|
||||
|
||||
`docs/scripts/m3-touch-targets.py` read comments as code. A KDoc line on the
|
||||
picker's row composable explaining that it used `Card(onClick = …)` "rather than
|
||||
a `Modifier.clickable`" was counted as a bare clickable chain, which breaks a
|
||||
zero budget and so breaks `./gradlew check`. The comment was first reworded to
|
||||
get around it, which was the wrong fix -- the sentence was right.
|
||||
|
||||
`m3-audit.sh` already solves this for the colour check and says why, at
|
||||
`NOT_A_COMMENT`: "a note *about* a hardcoded colour counts as one -- which
|
||||
happened the first time a call site was fixed and the commit explained what it
|
||||
had replaced."
|
||||
|
||||
Fixed on 2026-10-03, and the KDoc restored. Two things were worth knowing while
|
||||
looking:
|
||||
|
||||
- **The other two checkers already had it.** `m3-spacing-positions.py` skips
|
||||
`\s*(//|\*|/\*)` and `m3-title-case.py` skips the same three prefixes as a
|
||||
`startswith` tuple. Only the touch-target one was missing the two block-comment
|
||||
prefixes, so the fix is the house rule rather than a new one.
|
||||
- **The same function had the mirror bug, which is the dangerous direction.** It
|
||||
looked for `minimumInteractiveComponentSize` in a two-line window without
|
||||
stripping comments, so a comment above a real bare `.clickable` that merely
|
||||
mentioned the modifier would have excused it. A false positive is noise; that
|
||||
is a miss. Both lines of the window are now read as code only.
|
||||
|
||||
The fix is a prefix test and not block tracking, deliberately: a `/* … */` body
|
||||
line that does not start with `*` still reads as code. There are 539 block
|
||||
comments under `ui/` and not one such line, because every KDoc here stars its
|
||||
body -- and three identical rules spelled three ways would be worse than one
|
||||
limitation written down.
|
||||
@@ -0,0 +1,542 @@
|
||||
# Bringing up the iOS target
|
||||
|
||||
What it takes to build and run Mantra on an iPhone, phased, following the route
|
||||
the Curated fork already walked. Every one of its findings is diagnosed and
|
||||
reproducible here, so the work is porting a known answer rather than
|
||||
re-discovering it.
|
||||
|
||||
The headline is that almost nothing is missing. The `iosArm64` and
|
||||
`iosSimulatorArm64` targets are **already declared** in
|
||||
[composeApp/build.gradle.kts:36](../composeApp/build.gradle.kts), gated behind
|
||||
`OperatingSystem.current().isMacOsX` the way everything in the composite chain is;
|
||||
`composeApp/src/iosMain` holds ten `actual` files that match `jvmMain`'s ten
|
||||
one-for-one, so no `expect` is unimplemented; `commonMain` has no `java.*` or
|
||||
`javax.*` import anywhere; quartz 1.14.0 publishes `iosArm64` and
|
||||
`iosSimulatorArm64` klibs; and `iosApp/` is byte-identical to Curated's apart from
|
||||
brand tokens and one icon file, Xcode build phase and all.
|
||||
|
||||
What stands in the way is five concrete defects, which Curated hit in the order a
|
||||
build hits them — configure, compile, link, connect, sign — and fixed in eight
|
||||
commits.
|
||||
|
||||
## Where this stands
|
||||
|
||||
Phases 0 to 4 are **done and on this branch**, verified on a linux host; Phase 5
|
||||
needs two decisions and an asset, and Phases 6 and 7 need a mac. Nothing below was
|
||||
rewritten after the fact — each phase's text is as it was planned, with a
|
||||
**Landed** note where what happened is worth knowing.
|
||||
|
||||
| phase | state | commit |
|
||||
|---|---|---|
|
||||
| 0 bootstrap and baseline | done — 1174 + 159 tests green | (no commit) |
|
||||
| 1 advance the library chain | done — linux unchanged, as predicted | `a8f0e26f`, `1b8df6b4`, `dda2f95c` |
|
||||
| 2 Room constructor and cancellation | done | `73b2c8ae`, `fc195832` |
|
||||
| 3 unbreak the ios link tasks | done — linux half only | `d024838b` |
|
||||
| 4 ktor on Darwin | done — android and jvm halves only | `e6c3c8a2` |
|
||||
| 5 signing, icon, version | **not started** — needs decisions | — |
|
||||
| 6 what only a mac can find | **not started** — needs a mac | — |
|
||||
| 7 write down what it cost | in progress — this section | — |
|
||||
|
||||
`:composeApp:check` passes at the tip: lint, the m3 audit's ten counts, and all
|
||||
three test suites — `jvmTest` 1174, `testDebugUnitTest` 563, the library's own 159.
|
||||
Every count is the Phase 0 baseline, unmoved.
|
||||
|
||||
|
||||
## The eight commits, and what each one is here
|
||||
|
||||
| Curated | what it did | how it lands here |
|
||||
|---|---|---|
|
||||
| `94de86a3`, `cbf23640`, `64b37447` | three gitlink bumps of `lightning-kmp-app`, carrying a secp256k1-kmp build fix | the same three bumps, verbatim — Mantra is pinned at their exact parent |
|
||||
| `987df2c8` | `@ConstructedBy`, `@Throws(CancellationException)`, an unused import, `TEAM_ID` | the same, minus `TEAM_ID`, which is account-specific |
|
||||
| `aa9312f0` | comma-free `commonTest` names, plus a disabled compiler phase | **rewritten**, not cherry-picked — see Phase 3 |
|
||||
| `b9140c6a` | the Darwin ktor engine | near-verbatim; the shape it assumes is the shape here |
|
||||
| `bd47c841` | app icon and `MARKETING_VERSION` | needs Mantra artwork |
|
||||
| `a673c85e` | `versionName` | a release decision, not a port |
|
||||
|
||||
## What is already done for you
|
||||
|
||||
**The pin lines up exactly.** Mantra records `lightning-kmp-app` at
|
||||
`84cc44c855e3d173e26016b807c449153a3af597`. That is the *parent* of Curated's
|
||||
`94de86a3`. The three bumps are therefore a straight walk along the same gitlinks —
|
||||
`84cc44c` → `2dcd4fb` → `db15e59` → `e0d2b82` — with no rebase and no divergence
|
||||
to reconcile.
|
||||
|
||||
**The library's own iOS gating is already right.** `lightning-kmp-app/library`
|
||||
declares `iosArm64()` and `iosSimulatorArm64()` under the same `isMacOsX` guard,
|
||||
and sets `kotlin.native.enableKlibsCrossCompilation=true`. Nothing in the outer
|
||||
build has to teach it about apple targets.
|
||||
|
||||
**`commonMain` is clean, and the publish surface is small.** One `HttpClient {`
|
||||
construction in the whole module
|
||||
([NostrSocketClientFactory.kt:10](../composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientFactory.kt)),
|
||||
no explicit Coil `ImageLoader` anywhere, six `@Throws` annotations on the publish
|
||||
path. That is the same shape Curated's fixes were written against, so Phases 2 and
|
||||
4 are small and exact rather than a search.
|
||||
|
||||
**The audit does not look at tests.** `docs/scripts/m3-audit.sh` scans
|
||||
`composeApp/src/commonMain/kotlin/press/mantra/compose/ui` only, so Phase 3's sweep
|
||||
over test names cannot trip `./gradlew check`.
|
||||
|
||||
## What actually blocks it
|
||||
|
||||
Five things, independent of each other except for the order the build reveals them
|
||||
in:
|
||||
|
||||
1. **The chain will not configure on a mac.** secp256k1-kmp's `.def` declares
|
||||
`staticLibraries` for every target and the cinterop tool refuses to emit a klib
|
||||
while the archive is absent. Off a Linux host the Linux archives are never
|
||||
built, so the IDE sync and configuration fail with `cinterop file ... does not
|
||||
exist`. The fix — `9bcae25` in secp256k1-kmp, hardened by `8adf691` — stubs those
|
||||
archives under `if (!currentOs.isLinux)`. Its commit subject says "linux
|
||||
cinterop", which reads backwards from here: **it is the fix that makes the chain
|
||||
work on a mac**, and it is a strict no-op on Linux.
|
||||
|
||||
2. **Room cannot construct the database on native.**
|
||||
`MantraDatabaseConstructor` already exists in `commonMain`, `expect`,
|
||||
`@Suppress`ed and referenced by nothing. Without `@ConstructedBy` naming it,
|
||||
Room's KSP generates no `actual`, and on a target with no reflection there is no
|
||||
other way in.
|
||||
|
||||
3. **Swift cannot see a cancellation as a cancellation.** Six `@Throws` sites on
|
||||
the publish path name `NostrPublishException` and not
|
||||
`kotlin.coroutines.cancellation.CancellationException`, so a cancelled publish
|
||||
crosses the ObjC boundary as an unexpected throw.
|
||||
|
||||
4. **The iOS link tasks fail, twice over.** `linkDebugTestIosArm64` is rejected by
|
||||
Kotlin/Native over backticked test names containing a comma (`Name contains
|
||||
illegal characters: ','`), and `linkReleaseFrameworkIosArm64` crashes Kotlin
|
||||
2.4.10's release backend inside `StaticInitializersOptimization`.
|
||||
|
||||
5. **Nothing secure connects on a device.** CIO has no TLS implementation on
|
||||
Kotlin/Native — every `wss://` and `https://` call dies at the handshake with
|
||||
`TLS sessions are not supported on Native platform`. The relay sockets and
|
||||
Coil's image fetcher both sit on a default-engine client.
|
||||
|
||||
Everything below is ordered by that list.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — bootstrap, and a recorded baseline
|
||||
|
||||
Nothing is changed in this phase. Its output is a green build to compare against,
|
||||
because four of the five defects above are invisible until a mac is in the loop and
|
||||
you do not want to be guessing afterwards which failure you introduced.
|
||||
|
||||
**In this worktree, initialise the chain first.** Four nested submodules —
|
||||
`lightning-kmp-app` → `experimental/lightning-kmp` → `experimental/bitcoin-kmp` →
|
||||
`experimental/secp256k1-kmp` (→ `native/secp256k1`) — are unpopulated, and gradle
|
||||
dies early and confusingly with `Project with path ':library' not found`:
|
||||
|
||||
```bash
|
||||
git -c protocol.file.allow=always submodule update --init --recursive
|
||||
```
|
||||
|
||||
`protocol.file.allow=always` is required: the recorded url for `lightning-kmp-app`
|
||||
is the main checkout's path, and git refuses `file` transport for submodules by
|
||||
default. The last level (`native/secp256k1`, fetched from GitHub) can stall for
|
||||
ten minutes or more; let the recursive call do what it can and finish that one
|
||||
level from the main checkout's copy. Trust `git -C <level> rev-parse HEAD` per
|
||||
level rather than `git submodule status --recursive`, which lies after a
|
||||
per-level url override.
|
||||
|
||||
Then record the baseline:
|
||||
|
||||
```bash
|
||||
ANDROID_HOME=$HOME/Android/Sdk ./gradlew :composeApp:compileDebugKotlinAndroid :composeApp:jvmTest :lightning-kmp-app:library:jvmTest
|
||||
```
|
||||
|
||||
Note the jvmTest count and that it is green. `IdentitySwitchJvmTest`'s "last
|
||||
profile opened" case fails about one run in nine on a 5s DataStore timeout on a
|
||||
clean tree too — if you see exactly that, re-run before believing it.
|
||||
|
||||
**Deliverable:** no commit. A noted baseline.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — advance the library chain
|
||||
|
||||
The three gitlink bumps, kept as three commits so each one says what it carries.
|
||||
Walk the outer pin, and let the nested gitlinks come with it.
|
||||
|
||||
```bash
|
||||
git -C lightning-kmp-app fetch https://github.com/kngako/lightning-kmp-app.git 'refs/heads/*:refs/remotes/github/*'
|
||||
git -C lightning-kmp-app checkout --detach 2dcd4fbe1513421d100a4485583f167b1fdcd47c
|
||||
git -c protocol.file.allow=always -C lightning-kmp-app submodule update --init --recursive
|
||||
git add lightning-kmp-app && git commit
|
||||
```
|
||||
|
||||
In a worktree the submodule's `origin` is the **main checkout's copy**, not GitHub,
|
||||
so `git fetch origin` shows nothing new — fetch the `.gitmodules` url directly, as
|
||||
above. Each bump moves `experimental/lightning-kmp` too, which moves `bitcoin-kmp`,
|
||||
which moves `secp256k1-kmp`, so re-run `submodule update --init --recursive` inside
|
||||
`lightning-kmp-app` after each checkout or the chain is left mid-walk.
|
||||
|
||||
The three, in order:
|
||||
|
||||
| commit | pin | what it carries |
|
||||
|---|---|---|
|
||||
| 1 | `2dcd4fb` | secp256k1-kmp `9bcae25` — the stub archives that let the chain configure off Linux |
|
||||
| 2 | `db15e59` | the same fix merged into the `threshold` branches, so the nested gitlinks name branch tips rather than side commits |
|
||||
| 3 | `e0d2b82` | secp256k1-kmp `8adf691` — registers the stub task with `tasks.register` instead of the delegate provider, which Gradle 9 requires |
|
||||
|
||||
Commits 2 and 3 look like bookkeeping and are not: without 3 the stub task is
|
||||
never registered under a name, and without 2 the pins sit on commits that were
|
||||
rebased away.
|
||||
|
||||
**Verify, on Linux.** The whole fix is behind `if (!currentOs.isLinux)`, so Linux
|
||||
must be *unchanged*:
|
||||
|
||||
```bash
|
||||
ANDROID_HOME=$HOME/Android/Sdk ./gradlew :composeApp:compileDebugKotlinAndroid :composeApp:jvmTest :lightning-kmp-app:library:jvmTest
|
||||
```
|
||||
|
||||
Same test count, same green, as Phase 0. The library's own ~159 tests run inside
|
||||
the composite build and are the thing that proves the wiring still gives it what it
|
||||
expects.
|
||||
|
||||
**Verify, on a mac.** `./gradlew :composeApp:tasks` configures at all, and
|
||||
`:composeApp:compileKotlinIosArm64` resolves `fr.acinq.phoenix:lightning-kmp-app`.
|
||||
That is the phase's actual purpose.
|
||||
|
||||
> Suggested message, commit 1:
|
||||
> `build: carry secp256k1-kmp's fix for configuring off a linux host`
|
||||
> — and say in the body that the upstream subject reads the other way round.
|
||||
|
||||
**Landed** as `a8f0e26f`, `1b8df6b4`, `dda2f95c`. All five levels now sit exactly
|
||||
where Curated's do: `e0d2b82` → `e5b22f5d` → `9e3c4f8` → `8adf691` → `7a35fde1`.
|
||||
|
||||
Two things worth knowing. First, the walk across all three bumps touches **no
|
||||
Kotlin source anywhere in the chain** — the entire payload is eleven
|
||||
`build.gradle.kts` and `settings.gradle.kts` files in secp256k1-kmp — so gradle
|
||||
correctly reports `jvmTest UP-TO-DATE` afterwards. That reads like a stale cache
|
||||
and is not one; a forced `--rerun` returns the same 1174 and 159.
|
||||
|
||||
Second, the bump carries four fixes beyond the stub archives, because
|
||||
`a8cc3ec..8adf691` is seven commits rather than three: the `generateHeaders`
|
||||
output paths (which were `generated./jni`, with a stray dot, and whose temp
|
||||
directory was being cleared with a `delete()` that silently does nothing on a
|
||||
non-empty directory), Gradle 10's removal of the delegated-property DSL, `Project`
|
||||
objects passed as dependency notation, and `compileSdk 35` → `36` in
|
||||
secp256k1-kmp's `jni/android`. The last of those is the only one that can touch an
|
||||
android build here, and it is below Mantra's own 37.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — declare the Room constructor and cancellation to Swift
|
||||
|
||||
Four small edits, one commit. All of it compiles on Linux.
|
||||
|
||||
**`@ConstructedBy`.** Add the import and the annotation above
|
||||
[MantraDatabase.kt:369](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt)'s
|
||||
`@ColumnTypeConverters`:
|
||||
|
||||
```kotlin
|
||||
import androidx.room3.ConstructedBy
|
||||
…
|
||||
@ConstructedBy(MantraDatabaseConstructor::class)
|
||||
@ColumnTypeConverters(MantraConverters::class)
|
||||
abstract class MantraDatabase: RoomDatabase() {
|
||||
```
|
||||
|
||||
The `expect object` it names already exists and needs no edit. This is the one
|
||||
change in the whole plan that **also changes the JVM and Android builds**: Room's
|
||||
KSP starts generating an `actual` for every target rather than none, so the
|
||||
annotation that unblocks iOS is the annotation most likely to move something
|
||||
underneath `:composeApp:jvmTest`. Run it and read the output rather than assuming.
|
||||
|
||||
**Cancellation on the publish path.** Add
|
||||
`kotlin.coroutines.cancellation.CancellationException::class` to the six existing
|
||||
`@Throws`, and give `EventPublishTransport.publishEvent` one it does not have:
|
||||
|
||||
- [RelayPool.kt:289](../composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt)
|
||||
- [RelaysSocketManager.kt:103](../composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt) and `:118`
|
||||
- [NostrPublisherRepository.kt:33](../composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrPublisherRepository.kt), `:55` and `:66`
|
||||
- [EventPublishTransport.kt:31](../composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/EventPublishTransport.kt) — new, naming `NostrPublishException` as well
|
||||
|
||||
Keep the fully-qualified style the surrounding annotations already use.
|
||||
|
||||
**One unused import.** `import kotlin.native.Platform` in
|
||||
[Platform.ios.kt](../composeApp/src/iosMain/kotlin/press/mantra/compose/Platform.ios.kt)
|
||||
shadows the app's own `Platform` interface. Delete the line. Nothing on Linux
|
||||
compiles `iosMain`, so this one is carried on Curated's word and the compiler's,
|
||||
later.
|
||||
|
||||
**Verify, on Linux:** `:composeApp:compileDebugKotlinAndroid :composeApp:jvmTest`.
|
||||
**On a mac:** `:composeApp:compileKotlinIosArm64`.
|
||||
|
||||
> `feat(ios): declare the Room constructor and publish cancellation to swift`
|
||||
|
||||
**Landed** as two commits rather than one — `73b2c8ae` for `@ConstructedBy` alone,
|
||||
`fc195832` for the seven annotations and the import — because the first was the
|
||||
only change in the plan with real linux-side risk and deserved to be readable on
|
||||
its own.
|
||||
|
||||
The risk was real and benign. Room's KSP now emits an `actual object
|
||||
MantraDatabaseConstructor` for **android and jvm as well**, each returning
|
||||
`MantraDatabase_Impl()`, where before it emitted none at all and the `expect` sat
|
||||
`@Suppress`ed and unreferenced. `jvmTest` is unmoved at 1174. A side effect worth
|
||||
noting: `MantraDatabaseJvmTest`'s comment about the no-`Context` builder overload
|
||||
being available "because `MantraDatabaseConstructor` gives Room a way to
|
||||
instantiate without reflection" was aspirational when written and is now true.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — unbreak the iOS link tasks
|
||||
|
||||
Two unrelated link failures, one commit, because they are the same symptom from the
|
||||
outside: `./gradlew :composeApp:linkDebugTestIosArm64` and
|
||||
`:composeApp:linkReleaseFrameworkIosArm64` both fail.
|
||||
|
||||
### The comma names — rewrite, do not cherry-pick
|
||||
|
||||
Curated's `aa9312f0` touched 38 `commonTest` files. **Ten of them do not exist
|
||||
here** (`GroupEventProposalTest`, `CuratedSchemaEventTest`, `CuratedEntryEventTest`,
|
||||
`CuratedSchemaTemplateTest`, `GroupCuratedEntryTest`, `GroupCuratedSchemaTest`,
|
||||
`GroupPostTest`, `CuratedSuggestionTest`, `MarmotRoomIdentityTest`,
|
||||
`MarmotPayloadBoundsTest`, `FrostSigningMembersTest`) — they belong to the fork's
|
||||
curated-list and Marmot-v2 lines, neither of which Mantra has pulled. Meanwhile
|
||||
Mantra has test files Curated does not: `ProjectEventTest`,
|
||||
`ChapterProposalSizeTest`, `UploadChaptersStateTest`, `UploadReviewTest`,
|
||||
`SubgroupLineageTest`, `MarkdownBlockTest`, `MarkdownProducerTest`,
|
||||
`ChapterTextTest`.
|
||||
|
||||
So do the sweep over **Mantra's own tree**: 84 backticked `fun` names across 35
|
||||
files in `composeApp/src/commonTest`. Every comma in every one of them is followed
|
||||
by exactly one space — verified, no exceptions — so the rewrite is total and
|
||||
unambiguous:
|
||||
|
||||
```bash
|
||||
grep -rl 'fun `[^`]*,[^`]*`' composeApp/src/commonTest --include='*.kt' \
|
||||
| xargs perl -i -pe 's/(fun `[^`]*`)/($x=$1)=~s{, }{ }g; $x/ge'
|
||||
```
|
||||
|
||||
Then read the diff. A name losing its comma loses a little of its rhythm and none
|
||||
of its meaning (`read as its kind, tags and content` → `read as its kind tags and
|
||||
content`); if any of the 84 reads badly afterwards, reword that one by hand rather
|
||||
than leaving a comma behind.
|
||||
|
||||
**Leave `jvmTest` alone.** It has 138 comma names and Kotlin/Native never compiles
|
||||
it. Curated left it alone for the same reason, and widening the sweep would put 138
|
||||
cosmetic edits in a commit that is about a link failure.
|
||||
|
||||
### The release backend crash
|
||||
|
||||
Append to `composeApp/build.gradle.kts`, after the `kotlin { }` block and before
|
||||
`android {` at line 161 — the same place Curated put it:
|
||||
|
||||
```kotlin
|
||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinNativeLink>().configureEach {
|
||||
if (name.startsWith("linkRelease")) {
|
||||
toolOptions.freeCompilerArgs.add("-Xdisable-phases=RemoveRedundantCallsToStaticInitializersPhase")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
With the comment Curated wrote, which is the valuable part: the pass is an LTO
|
||||
optimization, so only optimized links reach it (which is why the debug test binary
|
||||
links fine); it assumes every function in the call graph has at least one statement
|
||||
in its body; something in this module's graph arrives with an empty one; and
|
||||
disabling it costs the redundant initializer calls it would have removed and
|
||||
nothing else.
|
||||
|
||||
`tasks.withType<KotlinNativeLink>()` matches nothing on Linux, so this is inert
|
||||
there — but the class resolves from the KMP plugin's classpath on any host, so the
|
||||
script still has to compile. `:composeApp:compileDebugKotlinAndroid` proves that
|
||||
much.
|
||||
|
||||
**Expect this one not to be the end of it.** The crash is a property of the call
|
||||
graph, and Mantra's graph is a superset of Curated's — projects, chapter uploads,
|
||||
multi-parent subgroups, the markdown producer. The same phase may crash somewhere
|
||||
else, or a different phase may crash. Treat the stack trace as the instruction:
|
||||
disable the named phase, and write down why.
|
||||
|
||||
**Verify, on a mac:** `:composeApp:linkDebugTestIosArm64` and
|
||||
`:composeApp:linkReleaseFrameworkIosArm64`.
|
||||
**On Linux:** `:composeApp:compileDebugKotlinAndroid :composeApp:jvmTest` — the
|
||||
renamed tests must still run, and the count must not move.
|
||||
|
||||
> `build(ios): unbreak the ios link tasks`
|
||||
|
||||
**Landed** as `d024838b`, the linux-verifiable half. The sweep ran exactly as
|
||||
predicted: 84 names in 35 files, 84 changed lines, every one of them a `fun` line
|
||||
and no other line in the tree touched. Both renamed tests show up in the results
|
||||
under their new names, under `jvmTest` and `testDebugUnitTest` alike, and the 138
|
||||
commas left in the results are `jvmTest`'s own — the set the plan leaves alone.
|
||||
|
||||
Two of the 84 needed rewording by hand, where the comma had been carrying real
|
||||
work rather than pausing: `EOSE does not end a live subscription, CLOSED does`
|
||||
became `a live subscription ends on CLOSED and not on EOSE`, and `a page says
|
||||
which chronicle it is, where in it, and who for` became `... and where in it and
|
||||
who for`. The other 82 lost only a pause.
|
||||
|
||||
The `-Xdisable-phases` block is in but **unproven** — no `KotlinNativeLink` task
|
||||
exists on this host for it to match, so all that is verified is that the build
|
||||
script still compiles. Whether this module's call graph crashes the same phase
|
||||
Curated's did is Phase 6's question.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — put every ktor client on Darwin
|
||||
|
||||
CIO has no TLS on Kotlin/Native. Pick the engine per platform instead of letting
|
||||
the classpath pick it.
|
||||
|
||||
**Catalogue entry**, beside `ktor-client-cio` in
|
||||
[gradle/libs.versions.toml:82](../gradle/libs.versions.toml):
|
||||
|
||||
```toml
|
||||
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
|
||||
```
|
||||
|
||||
**Dependency**, inside the existing `isMacOsX`-gated `iosMain.dependencies` block at
|
||||
[composeApp/build.gradle.kts:154](../composeApp/build.gradle.kts). It goes inside
|
||||
the gate, which is why Linux resolution is untouched by this phase.
|
||||
|
||||
**Three new files**, an `expect` and two `actual`s, in
|
||||
`press.mantra.compose.network`:
|
||||
|
||||
- `commonMain/.../PlatformHttpClientEngine.kt` — `internal expect fun platformHttpClientEngineFactory(): HttpClientEngineFactory<*>`
|
||||
- `androidMain/.../PlatformHttpClientEngine.android.kt` — `= CIO`
|
||||
- `jvmMain/.../PlatformHttpClientEngine.jvm.kt` — `= CIO`
|
||||
- `iosMain/.../PlatformHttpClientEngine.ios.kt` — `= Darwin`
|
||||
|
||||
**Two call sites.** `defaultSocketsHttpClient` becomes
|
||||
`HttpClient(platformHttpClientEngineFactory()) { … }`, keeping its `WebSockets`
|
||||
block as-is. And `MantraApp` gains the singleton Coil loader, because Coil's
|
||||
default ktor fetcher builds a bare `HttpClient()` and would fail on `https://`
|
||||
images exactly as the sockets did:
|
||||
|
||||
```kotlin
|
||||
private val imageHttpClient by lazy { HttpClient(platformHttpClientEngineFactory()) }
|
||||
|
||||
@Composable
|
||||
fun MantraApp(…) {
|
||||
setSingletonImageLoaderFactory { context ->
|
||||
ImageLoader.Builder(context)
|
||||
.components { add(KtorNetworkFetcherFactory(httpClient = { imageHttpClient })) }
|
||||
.build()
|
||||
}
|
||||
|
||||
MantraTheme { … }
|
||||
}
|
||||
```
|
||||
|
||||
`setSingletonImageLoaderFactory` goes *above* `MantraTheme`, at the top of the
|
||||
composable's body, so it is installed before the first `AsyncImage` composes.
|
||||
`MantraApp` is the only place both call sites are reachable from, and Mantra has no
|
||||
`ImageLoader` configuration today to reconcile with.
|
||||
|
||||
**Verify, on Linux:** `:composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinJvm :composeApp:jvmTest` — the android and jvm `actual`s and both call sites compile here, which is most of the phase.
|
||||
**On a mac:** `:composeApp:compileKotlinIosArm64`, then the real test — a device or
|
||||
simulator that reaches a relay over `wss://` and renders an avatar over `https://`.
|
||||
|
||||
> `fix(ios): build ktor clients on Darwin so wss/https work on a device`
|
||||
|
||||
**Landed** as `e6c3c8a2`. The android and jvm `actual`s and both call sites
|
||||
compile; the Darwin `actual` and the thing the phase is actually for do not, here.
|
||||
`ktor-client-darwin` 3.5.2 has never been resolved on this host — the gradle cache
|
||||
holds 3.3.3 and 3.4.1 from older work and nothing newer — because the dependency
|
||||
sits inside the `isMacOsX` gate. It resolves on the mac at the same `ktor` version
|
||||
ref Curated built against.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — signing, icon, version
|
||||
|
||||
Three things Curated's last two commits did that **cannot be ported**, only
|
||||
decided. Each needs an answer from you before it can be written.
|
||||
|
||||
**`TEAM_ID`.** [iosApp/Configuration/Config.xcconfig](../iosApp/Configuration/Config.xcconfig)
|
||||
has `TEAM_ID=` empty, and the bundle identifier is built from it
|
||||
(`press.mantra.ios$(TEAM_ID)`). Curated committed its development team id in the
|
||||
clear, which is normal for an xcconfig — a team id is not a secret — but it signs
|
||||
builds to a particular account. The expectation is that Mantra uses **the same one
|
||||
Curare does, `ZK3TQ662GB`**; that still wants confirming before it is written,
|
||||
since it decides whose account the build belongs to and the bundle identifier is
|
||||
derived from it.
|
||||
|
||||
**The app icon.** Curated replaced its single `app-icon-1024.png` with a 1024
|
||||
light variant and a 1024 dark one, adding the `luminosity: dark` appearance to
|
||||
`Contents.json` and leaving the `tinted` slot filenameless so iOS derives it.
|
||||
Mantra's `AppIcon.appiconset` still holds one 1024 file and one entry. The
|
||||
*structure* ports; the artwork does not. Two 1024×1024 PNGs are needed.
|
||||
|
||||
**`MARKETING_VERSION`.** Mantra's xcconfig says `1.0` while
|
||||
`android { versionName }` says `0.1.2` — the two have simply never been reconciled
|
||||
because nothing shipped on iOS. Curated set both to the `0.1.x` line. Pick one
|
||||
number for both; the mismatch is the bug, not either value.
|
||||
|
||||
Curated's `a673c85e` moved `versionName` from `0.1.2` to `0.1.1`, which is a
|
||||
release-bookkeeping step on their tree and means nothing on this one. Do not port
|
||||
it.
|
||||
|
||||
> `feat(ios): the app icon, the signing team, and one version across both stores`
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — the part only a mac can find
|
||||
|
||||
Phases 1 to 4 fix everything Curated hit. They do not fix what Curated never had,
|
||||
and Mantra has pulled four lines of work the fork does not carry: projects, chapter
|
||||
uploads, multi-parent subgroups, and the markdown producer. None of it has ever
|
||||
been compiled for `iosArm64`.
|
||||
|
||||
So the honest shape of this phase is a loop, not a list:
|
||||
|
||||
```bash
|
||||
./gradlew :composeApp:compileKotlinIosArm64
|
||||
./gradlew :composeApp:linkDebugTestIosArm64
|
||||
./gradlew :composeApp:iosSimulatorArm64Test
|
||||
./gradlew :composeApp:linkReleaseFrameworkIosArm64
|
||||
```
|
||||
|
||||
then Xcode, whose build phase already runs
|
||||
`:composeApp:embedAndSignAppleFrameworkForXcode` against the `ComposeApp`
|
||||
framework, deployment target 18.2.
|
||||
|
||||
Known places to look first, each a guess with a reason:
|
||||
|
||||
- **`FilePicker.ios.kt`** is the newest `iosMain` actual and the one behind chapter
|
||||
uploads. It exists, so it compiles in principle; whether `UIDocumentPicker`
|
||||
actually returns bytes on a device is untested anywhere.
|
||||
- **Anything reached only from the new screens** — the `StaticInitializers` crash in
|
||||
Phase 3 is call-graph-shaped, and these four lines are what widened the graph.
|
||||
- **`kotlinx.datetime` and `okio` edges** in the markdown and chapter paths, which
|
||||
are the usual source of a jvm-only overload that compiles everywhere but native.
|
||||
|
||||
Each fix found here is its own commit, in the voice of what broke rather than
|
||||
"ios fixes".
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — write down what it cost
|
||||
|
||||
This document, revised from a plan into a record: which of the five defects
|
||||
behaved as predicted, what Phase 6 actually found, and which compiler phase is
|
||||
disabled with which stack trace behind it. Then revise its row in
|
||||
[docs/README.md](./README.md), which this document already carries, so it describes
|
||||
what happened rather than what was planned.
|
||||
|
||||
The `-Xdisable-phases` flag in particular needs a home in prose. It is a load-
|
||||
bearing workaround for an upstream compiler bug, it is invisible from the source,
|
||||
and the next person to bump Kotlin should be able to find out in one grep whether
|
||||
they can take it out again.
|
||||
|
||||
---
|
||||
|
||||
## What to do if there is no mac
|
||||
|
||||
Phases 1 to 4 are worth landing anyway, and all four are verifiable on Linux for
|
||||
the half that Linux compiles:
|
||||
|
||||
| phase | Linux-verifiable | needs a mac |
|
||||
|---|---|---|
|
||||
| 1 | the fix is `if (!currentOs.isLinux)`, so "unchanged" is the whole check | that it configures at all |
|
||||
| 2 | `@ConstructedBy` and all seven `@Throws` | the unused-import removal |
|
||||
| 3 | the renamed tests still run; the build script still compiles | both link tasks |
|
||||
| 4 | the `expect`, the android and jvm `actual`s, both call sites | the Darwin `actual`, and TLS actually working |
|
||||
| 5 | nothing | nothing — it needs decisions, not a host |
|
||||
|
||||
Phase 2's `@ConstructedBy` is the one with real Linux-side risk, since it changes
|
||||
what Room's KSP generates for every target. Land it on its own and read
|
||||
`:composeApp:jvmTest` carefully.
|
||||
@@ -1071,6 +1071,13 @@ want to remove within a week.
|
||||
- **Creator or owner on a membership.** One tag and one column away, deliberately
|
||||
left out — see Phase 1.
|
||||
- **Removal** until Phase 8, **cross-room** until Phase 9.
|
||||
- **A room of the project's own** — built since, in
|
||||
[subgroups-for-entities.md](./subgroups-for-entities.md). A project can be
|
||||
given a differently-keyed subgroup of its room, and the parent's quorum signs
|
||||
which project the room is for. It does not move the project: the rows stay in
|
||||
the parent's tables, signed by the parent's quorum, and the child's members may
|
||||
hold none of them. Giving the child its own copy is the cross-room question
|
||||
above and is still Phase 9.
|
||||
- **Project-level approval or progress.** The server has
|
||||
`artifactApproval`, `artifactVersionApproval` and
|
||||
`translationArtifactVersionApproval`, none of which exists here either. A
|
||||
|
||||
@@ -14,6 +14,14 @@ a pointer on desktop has to land on.
|
||||
Components that carry their own minimum -- IconButton, Button, Checkbox, ListItem and the
|
||||
rest of material3 -- are not `.clickable` call sites and never appear here.
|
||||
|
||||
Comments are not code, in either direction. A KDoc line explaining that a row uses a
|
||||
clickable `Card` *rather than* a `Modifier.clickable` was counted as a call site, which
|
||||
breaks a zero budget and so breaks `./gradlew check`; and a comment above a real call site
|
||||
that merely mentions `minimumInteractiveComponentSize` would have excused it. The first is
|
||||
noise and the second is a miss, so both lines of the window are read as code only. This is
|
||||
the same thing m3-audit.sh's `NOT_A_COMMENT` exists for, spelled as m3-title-case.py
|
||||
spells it -- see the note there on a colour comment counting as a colour.
|
||||
|
||||
Usage: m3-touch-targets.py [--list]
|
||||
"""
|
||||
import os, re, sys
|
||||
@@ -21,6 +29,18 @@ import os, re, sys
|
||||
UI = 'composeApp/src/commonMain/kotlin/press/mantra/compose/ui'
|
||||
|
||||
|
||||
def _is_comment(line):
|
||||
"""Whether `line` is a comment line: `//`, or a `/*` block's opener or body.
|
||||
|
||||
A prefix test rather than block tracking, because that is what the other two checkers
|
||||
do and three identical rules spelled three ways is worse than one limitation. The
|
||||
limitation: a `/* ... */` body line that does not start with `*` reads as code. There
|
||||
are 539 block comments under `ui/` and not one such line, since every KDoc here stars
|
||||
its body -- if that ever stops being true, this is the function to make stateful.
|
||||
"""
|
||||
return line.lstrip().startswith(('//', '*', '/*'))
|
||||
|
||||
|
||||
def offenders():
|
||||
found = []
|
||||
for root, _, files in os.walk(UI):
|
||||
@@ -32,11 +52,14 @@ def offenders():
|
||||
for i, line in enumerate(lines):
|
||||
if '.clickable' not in line:
|
||||
continue
|
||||
if line.lstrip().startswith('//') or line.startswith('import '):
|
||||
if _is_comment(line) or line.startswith('import '):
|
||||
continue
|
||||
# The modifier may sit on this line or on the one above, since a chain
|
||||
# broken across lines is the common shape.
|
||||
window = '\n'.join(lines[max(0, i - 1):i + 1])
|
||||
# broken across lines is the common shape. Comment lines are dropped from
|
||||
# the window rather than kept: an exemption has to be in the code.
|
||||
window = '\n'.join(
|
||||
l for l in lines[max(0, i - 1):i + 1] if not _is_comment(l)
|
||||
)
|
||||
if 'minimumInteractiveComponentSize' in window:
|
||||
continue
|
||||
found.append((path, i + 1, line.strip()[:90]))
|
||||
|
||||
@@ -0,0 +1,580 @@
|
||||
# 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.
|
||||
```
|
||||
@@ -42,6 +42,13 @@ A subgroup is an ordinary Marmot room with an ordinary threshold key, made by an
|
||||
ordinary ChillDKG ceremony, plus a **birth certificate** — the parent group's
|
||||
signature over the child's room id — carried on the child's key state.
|
||||
|
||||
A certificate can also say what the room is *for* — a dialect, an artifact, a
|
||||
translation or a project in the parent's own tables — which is one optional tag
|
||||
and is the whole of [subgroups-for-entities.md](./subgroups-for-entities.md).
|
||||
Nothing below changes for it: `certifies` never reads it, and a certificate with
|
||||
no origin at all is valid, which it has to be, since every one signed before that
|
||||
tag existed has none.
|
||||
|
||||
The key state grows two tags for it, not one, and only the first is an artefact:
|
||||
|
||||
| tag | what it is |
|
||||
|
||||
Reference in new issue
Block a user