The deviations table every doc in here carries, and it is the most-read part of them. Seven entries, of which three are worth the reader's time. `proposeBirthCertificate` was planned to take an `origin` parameter; it already takes the whole `DkgSession`, so it reads `key.origin()` and nothing in the repository or the ritual view model changed. A parameter would have been a second source for one fact, and the proposer's copy of it at that. Phase 3 put a button on four screens and Phase 7 replaced all four with one `LazyListScope` extension, so they were written twice. The giveaway was already in Phase 0.3, which argued the dialect's entry point had to be a screen because a card cannot *grow* into a list -- the same argument applied to all four, and the section should have been planned where the buttons were. And Phase 8's own claim was wrong: the M3 adaptive floor cannot be raised by this work. The floors count `MaterialTheme.breakpoint`, `BoxWithConstraints`, the pane scaffolds and the navigation components; `readableContent()` and `NavigateBackButton` are neither, so a screen that follows every rule in CLAUDE.md adds nothing to either count. 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, which a detail screen is not, and they stay where phase 6 of the conformance work left them. Recorded rather than quietly dropped, because the next plan will want to make the same assumption. Pointers from docs/README.md, from docs/subgroups.md beside the multiple-parents line, and from docs/project-feature.md -- where a project's own room was the first thing that file said it lacked, and which now says the rows stay in the parent's tables all the same. Phase 8 of docs/subgroups-for-entities.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1085 lines
55 KiB
Markdown
1085 lines
55 KiB
Markdown
# Projects
|
||
|
||
Phases 0 through 7 are built. Phases 8 and 9 -- removing something from a
|
||
project, and projects that span rooms -- are not, and the reasoning for both is
|
||
below rather than anywhere else.
|
||
|
||
**Where the build chose differently**, each of which is explained in the phase
|
||
it belongs to:
|
||
|
||
| the plan said | the build did | why |
|
||
|---|---|---|
|
||
| a `creator` tag and an `owner` tag | a `creator` tag, and owners as `ContributorTag`s with `ROLE_OWNER` | a project has several owners, which is the cardinality `ContributorTag` is for |
|
||
| the room's project section is new | it replaced a stub | `ChatRoomDetailScreen` already had a heading, a permanent empty line and an `ImplementationPendingRoute`, under a `// TODO: Add projects...` |
|
||
| the `summarize` resolver is Phase 4 | the seam is Phase 4, the resolution is Phase 7c | two of its four call sites are composables and cannot await a database, so the names are pre-resolved in `FrostSigningViewModel` |
|
||
| three chronicle tests to add | three to add and four to fix | the existing suite compares a seeded room against `CHRONICLABLE_KINDS` itself, and one test asserts every chroniclable kind can also be *rebuilt* -- which projects deliberately cannot |
|
||
| around twenty new strings | eighteen, three of them already there | `projects`, `no_projects_exists_in_this_group` and `create_project` came with the stub |
|
||
| schema 21 | schema 22 | `mantra` took 21 for the subgroup parentage table while this branch was open; Phase 0 said to check for exactly this and checked the sibling fork rather than the base |
|
||
| the apply arms catch the foreign key violation | they let it out | `ChronicleManager.sweep` retries a page only on a *counted* failure, so catching it loses a membership that crosses a page boundary — and `applySignedEvent`, the reason for catching, already has its own try/catch |
|
||
|
||
The one thing found while building that the plan did not anticipate: a project
|
||
reaches a chronicle only off the `GroupSignedEvent` record, because there is no
|
||
`toProjectEvent` rebuild. `recordSignedEvents` catches its own failure so that a
|
||
ceremony that succeeded is not reported as failed, which leaves a narrow window
|
||
where a project row exists and its signed event does not -- a project this
|
||
device can show and can never hand on. `ChronicleAssemblyJvmTest.a project is
|
||
only ever chronicled off the record` holds that case open rather than letting it
|
||
be discovered later.
|
||
|
||
`mantra-db-models` — the platform's server-side Sequelize schema — has had three
|
||
models waiting for this app since before it existed: `project`, with a name and a
|
||
description, and the two join models that give it its contents,
|
||
`projectArtifactVersion` and `projectTranslationArtifactVersion`. A project is
|
||
how the platform says *this body of work*: a set of source versions to translate
|
||
from and a set of translations being produced against them, grouped under one
|
||
name so that funding, progress and membership have something to attach to.
|
||
|
||
This app has the whole tree underneath a project already — eight nip30303 kinds
|
||
from `Artifact` (30300) down to `TranslationChunk` (30309), each one an event the
|
||
group signs with its own threshold key and a Room row derived from it. What it
|
||
has no way to say is that two of those translations and three of those source
|
||
versions are one piece of work.
|
||
|
||
The server's schema is a relational description of a platform with accounts. This
|
||
app is a Marmot/MLS group with no accounts, no server and no authority but a
|
||
quorum of its own members. Every foreign key on `project` therefore has to be
|
||
re-answered, and saying how is most of the work this document does: one becomes a
|
||
tag of its own, one becomes a set of tags, one becomes the room, and one is out of
|
||
scope. The code to write is comparatively mechanical — the same eight-file shape
|
||
every nip30303 entity already has, three times over — and the one place it goes
|
||
quietly wrong is a role the parser has not been told about.
|
||
|
||
## What the server's schema says, and what survives
|
||
|
||
```
|
||
project
|
||
id string, nanoid
|
||
name string, not null
|
||
description text, not null
|
||
creatorId -> user, not null
|
||
ownerId -> owner, not null, UNIQUE
|
||
campaign hasOne campaign
|
||
hasMany projectArtifactVersion
|
||
hasMany projectTranslationArtifactVersion
|
||
|
||
projectArtifactVersion projectTranslationArtifactVersion
|
||
id id
|
||
creatorId -> user, NN creatorId -> user, NN
|
||
projectId -> project, NN projectId -> project, NN
|
||
artifactVersionId
|
||
-> artifactVersion, NN -> translationArtifactVersion, NN
|
||
```
|
||
|
||
Taking those fields one at a time:
|
||
|
||
**`name` and `description` survive** as the substance of the thing. By this
|
||
family's convention the long text goes in the event's `content` and the short
|
||
label gets a tag of its own — `ArtifactEvent` puts the name in content because an
|
||
artifact has no longer field, `ChapterEvent` puts `originalText` there and the
|
||
name in a `NameTag`. A project has both, so it follows the chapter: `content` is
|
||
the description, and `NameTag` carries the name.
|
||
|
||
**`creatorId` survives as a `CreatorTag` on the project event**, and the
|
||
difference between that and a column is the whole point. Every nip30303 entity a
|
||
member creates is now proposed for FROST signing rather than submitted, so the row
|
||
that lands is authored by *the room's own key* and carries no trace of who typed
|
||
it: `publicKey` is the room's id, and both Marmot columns are null on a
|
||
group-signed row. A `creatorPublicKey` column filled in from local knowledge would
|
||
be a field the signed event does not carry — which, under a signature, is the one
|
||
kind of mistake that is expensive. A `["creator", "<hex>"]` tag written into
|
||
the template *before* it is proposed is not that: it is inside the id the quorum
|
||
signs, so what lands is the group saying "this project was started by X" rather
|
||
than one device's note about who happened to insert a row.
|
||
|
||
That is a stronger statement than the server's column, and it is worth being clear
|
||
about what kind of statement it is. The creator does not sign the project event —
|
||
the room does — so this is a **group attestation, not proof of authorship**. A
|
||
member could propose a project crediting somebody else, and a quorum that does not
|
||
read the proposal would sign it. The mitigation is not cryptographic and does not
|
||
need to be: it is that the signing screen must show the creator, which makes it one
|
||
of the things a member is agreeing to. See Phase 4.
|
||
|
||
The proposer's identity is still recorded independently, on the
|
||
`FrostSigningSession` the proposal opened. The two can disagree, and when they do
|
||
the session is the fact about who acted while the tag is the group's claim about
|
||
whose work it is. Those are different questions and both are worth keeping.
|
||
|
||
**`ownerId` survives as `ContributorTag`s with an owner role — plural**, and it
|
||
buys something the structural answer could not. The structural answer is real and
|
||
stays: a project is signed by the group's threshold key at the room's derivation
|
||
path, so it says which *group* it belongs to by being signed at all, and
|
||
`chatRoomId` — a foreign key to `ChatRoom` with `onDelete = CASCADE` — carries that
|
||
exactly as on `MantraArtifact` and `MantraDialect`. What it cannot say is that a
|
||
project is the group's work and somebody *else's* property, which is the whole
|
||
reason the server has an `owner` with an `ownerEntity` join behind it: an owner
|
||
there can be an individual or an organization, and need not be the people doing
|
||
the work.
|
||
|
||
Pubkeys collapse that chain, because in this app an owner that is not a person is
|
||
a group, and a group is a pubkey — its room id. **A project can have several**, so
|
||
this is a set rather than a field: two organizations co-owning a translation, or a
|
||
publisher and the group that did the work, are ordinary cases, and the server's
|
||
`UNIQUE` on `project.ownerId` — one owner, one project, which looks like a
|
||
modelling slip on that side — is dropped rather than reproduced.
|
||
|
||
**Always emit at least one, and default to the room's own public key.** Not an
|
||
empty set with the room as an implied default: a project with no owner tag and a
|
||
project owned by the room would then be the same project, and making them the same
|
||
*event* means no reader ever has to know the default. The room's id is 64 hex
|
||
characters, so it is a well-formed `ContributorTag` value, and the common case —
|
||
"we own what we are making" — comes out as a real entry rather than an absence.
|
||
|
||
**`campaign` is out of scope.** There is no `Campaign` or `Pledge` anywhere in this
|
||
app — `grep -ril "campaign\|pledge\|satoshi"` over `commonMain` returns the
|
||
lightning wallet and `Zap` and nothing else — and nothing in this plan models one,
|
||
reads one or leaves a column for one. Funding is a separate feature that needs
|
||
projects to exist first.
|
||
|
||
**The two join models survive as they are**, minus `creatorId` — see
|
||
[Phase 1](#phase-1--three-events-and-their-tags) for why a membership gets no
|
||
creator tag of its own — and they are the interesting half.
|
||
|
||
## A creator tag, and owners on the `p` tag
|
||
|
||
Two fields, two shapes, and the thing that decides which is cardinality.
|
||
|
||
**One creator, so a tag of its own.** `CreatorTag`, tag name `creator`, a
|
||
single-value tag holding 64 hex characters, in `NameTag`'s shape. A project is
|
||
started by one person; there is no list to keep.
|
||
|
||
**Many owners, so `ContributorTag` with an owner role.** That is exactly what
|
||
`ContributorTag` is for — an open-ended set of people in a named role, which is
|
||
also how the translation side uses it for translators, proofreaders and editors.
|
||
A project owned jointly is the case the server's `owner` → `ownerEntity` join
|
||
exists to express, and a `p` tag per owner expresses it without a second table of
|
||
events.
|
||
|
||
Keeping them apart is worth a sentence because reusing `ContributorTag` for both
|
||
is the obvious move. It is wrong for the creator twice over: it would make a
|
||
single-valued field look like a list, and it would put two tags of the same name
|
||
on one event for no gain — see [the two traps](#the-two-traps-owners-still-have)
|
||
below, both of which the creator now simply avoids.
|
||
|
||
### `ROLE_OWNER` has to reach `KNOWN_ROLES`, in the same commit
|
||
|
||
```kotlin
|
||
private val KNOWN_ROLES = setOf(ROLE_TRANSLATOR, ROLE_PROOF_READER, ROLE_EDITOR)
|
||
…
|
||
val role = tag.getOrNull(2)?.takeIf { it in KNOWN_ROLES }
|
||
```
|
||
|
||
The filter is there for a good reason, which the file states: a stale relay hint
|
||
copied out of a kind:3 p-tag would otherwise render as `Role: wss://relay…` under
|
||
an author chip. The consequence for an unregistered role is specific and silent —
|
||
the tag parses, the role comes back `null`, the string stays inside the signed
|
||
event id, the event still verifies, and the project lands with no owners. Nothing
|
||
is logged, because nothing went wrong as far as the parser is concerned.
|
||
|
||
So `ROLE_OWNER = "owner"` goes into the constants **and** into `KNOWN_ROLES`, in
|
||
the same commit as the first event that emits it. That is [Phase
|
||
1](#phase-1--three-events-and-their-tags), and it is the first line of it. Note
|
||
that the app's three existing roles already diverge from NIP-F4, whose
|
||
spec-defined set is host/cohost/editor; this continues that divergence rather than
|
||
starting it, and the comment above `KNOWN_ROLES` should say so.
|
||
|
||
`ROLE_CREATOR` is **not** added. There is no creator role, because the creator is
|
||
not a contributor tag.
|
||
|
||
### The two traps owners still have
|
||
|
||
**`addUnique` keeps one tag per *name*, not per value.**
|
||
|
||
```kotlin
|
||
fun addUnique(tag: Array<String>): TagArrayBuilder<T> {
|
||
if (tag.isEmpty() || tag[0].isEmpty()) return this
|
||
tagList[tag[0]] = mutableListOf(tag) // replaces the whole list
|
||
return this
|
||
}
|
||
```
|
||
|
||
Emitting three owners with `addUnique` leaves one. Use `addUniqueValueIfNew`,
|
||
which appends and dedupes on `tag[1]` — the pubkey — so a key listed twice by a
|
||
form appears once. For a *set* of owners that is the correct semantics;
|
||
`addUniqueValueIfNew` was the wrong call only back when creator and owner shared
|
||
the tag name and deduping by pubkey would have collapsed a person who was both.
|
||
|
||
**Emit them sorted.** The id is a hash over the tag array as emitted, so the order
|
||
owners come out in is part of what the group signs. A multi-select hands them over
|
||
in selection order, which is nothing in particular. `DkgSession.participantPublicKeys`
|
||
already sorts for a version of this reason — "so two devices that assembled the
|
||
set in different orders store the same string" — and the same line makes a project
|
||
canonical, keeps the owner list on the detail screen stable, and costs a
|
||
`.sorted()`.
|
||
|
||
**Selecting by role, not position**, is no longer a trap: with the creator gone
|
||
from the `p` tag, every `ContributorTag` on a project event is an owner. Filter on
|
||
the role anyway — `it.role == ROLE_OWNER` — because that assumption is one future
|
||
role away from being false, and the failure would be an owner list quietly
|
||
containing somebody else.
|
||
|
||
### What the `p` tag costs the owners, and why it is acceptable here
|
||
|
||
A `p` tag is not an inert field in nostr. NIP-01 indexes single-letter tag names
|
||
and makes them queryable as `#p`, so it means "this pubkey is involved": it is what
|
||
relays index, what `Nip17Filters` subscribes on, and what a notification system
|
||
reads as a mention.
|
||
|
||
Inside a Marmot group none of that applies — these events are never on a relay as
|
||
themselves. But `BroadcastGroupSignedEventScreen` takes an event the group signed
|
||
and publishes it to public relays, and a broadcast project would put its owners'
|
||
keys into those relays' addressed-pubkey index, queryable, with a client free to
|
||
notify an owner about a project they were not told about.
|
||
|
||
For owners that is tolerable and arguably right: an owner *is* involved, which is
|
||
what the tag means, and a project the group chose to publish is one it chose to
|
||
publish under their names. It is still worth the broadcast screen saying whose keys
|
||
are in what is about to leave, and that belongs on the list of things that screen
|
||
should say rather than in this feature.
|
||
|
||
The creator escapes this entirely, which is a second small reason the split is the
|
||
right one: `creator` is multi-character, so it is not indexed, not queryable and
|
||
not an address, and a broadcast project carries the creator as data rather than as
|
||
a recipient.
|
||
|
||
### One thing not to copy
|
||
|
||
Six existing tags carry a stray `Tag` suffix in the *wire value* — `chapterIdTag`,
|
||
`chunkIdTag`, `dialectIdTag`, `translationIdTag`, `translationChapterIdTag`,
|
||
`translationChunkIdTag` — while `artifactId`, `artifactVersionId` and
|
||
`translationArtifactVersionId` do not. It is a copy-paste slip and it is
|
||
**unfixable**: those strings are inside event ids the group has already signed, so
|
||
renaming them would invalidate every signature over them. The new tag is plain
|
||
`creator`, and the slip is worth a comment where somebody would otherwise tidy it.
|
||
|
||
## Three kinds, and where they can go
|
||
|
||
The nip30303 band is full, and worse than full.
|
||
|
||
```
|
||
30300 Artifact 30310 TranslationContributorList / DKG proposal
|
||
30301 ArtifactVersion 30311 Translation / DKG host key
|
||
30302 Chapter 30312 Submission / DKG round 1
|
||
30303 Chunk 30313 DKG coordinator round 1
|
||
30304 Dialect 30314 DKG round 2
|
||
30305 TranslationAVContributorList 30315 DKG certificate
|
||
30306 TranslationArtifactVersion 30316 DKG failure
|
||
30307 TranslationChapterContribLst 30320-30325 FROST signing
|
||
30308 TranslationChapter 30326 GroupKeyState
|
||
30309 TranslationChunk 30327-30328 Chronicle
|
||
30329 SubgroupBirthCertificate
|
||
```
|
||
|
||
Three kinds are double-booked. `SubmissionEvent.KIND` is 30312 and
|
||
`DkgRitualEvents.ROUND_1` is 30312; 30310 and 30311 collide the same way. It has
|
||
been survivable because the two families travelled in different envelopes — a
|
||
ceremony's messages were NIP-17 gift wraps between participants and a submission
|
||
was a Marmot inner event — and since subgroups moved a child's ChillDKG into the
|
||
**parent's Marmot room** that separation is thinner than it was. None of the three
|
||
colliding nip30303 kinds has a producer (`TranslationEvent` has an inbound arm and
|
||
nothing that builds one; the contributor lists have neither), which is the only
|
||
reason this is a latent bug rather than a live one.
|
||
|
||
So: do not extend the 303xx nip30303 band, and do not fill the 30317–30319 gap,
|
||
which is the ceremony family's growth room. Projects take the three slots after
|
||
the highest kind in use:
|
||
|
||
```
|
||
30330 ProjectEvent
|
||
30331 ProjectArtifactVersionEvent
|
||
30332 ProjectTranslationArtifactVersionEvent
|
||
```
|
||
|
||
Worth doing in passing, in Phase 0, because it costs a comment and this document
|
||
is the only place anyone will look: a note on `DkgRitualEvents` and on
|
||
`SubmissionEvent` saying the two bands overlap and which side has no producer. A
|
||
collision nobody has written down is one somebody resolves by moving the wrong
|
||
family.
|
||
|
||
## One event per membership, not one list per project
|
||
|
||
Two shapes are available for "the versions in this project".
|
||
|
||
**A list event per project**, replaceable, carrying every member as a tag. One
|
||
event to sign when membership changes, and removal is free — the new list simply
|
||
omits it.
|
||
|
||
**An event per membership**, which is what the server's schema describes and what
|
||
every other entity in this family is. Append-only; removal needs something extra.
|
||
|
||
Take the second, for three reasons. The first is that every nip30303 entity here
|
||
is its own event and its own row, and the *only* list-shaped kinds in the family —
|
||
the three contributor lists, 30305/30307/30310 — are precisely the ones that were
|
||
never made to work. `applyInnerEvent` has no arm that writes a row for any of
|
||
them, `MantraTranslationArtifactVersionContributor` has a companion full of
|
||
`TODO`s assembling an event out of empty strings, and the chronicle leaves all
|
||
three out on the grounds that chronicling them "would cost bytes and restore
|
||
nothing". The list shape in this codebase is a shape that has failed twice.
|
||
|
||
The second is batch signing. A project with its first contents is a natural batch
|
||
— the project as lead, its memberships as dependents, each carrying an id that is
|
||
a hash over the group's key at the room's path and so cannot be known by the
|
||
screen that proposes it. That is exactly `proposeSigningBatch(lead, dependents)`,
|
||
and exactly what an artifact and its initial version already do. A list event
|
||
would have to be signed *after* the project, in a second ceremony, because it has
|
||
to name the project's id.
|
||
|
||
The third is that a membership is a thing a group decided, and one quorum per
|
||
decision is the grain this app signs at. A replaceable list makes "we added
|
||
Setswana to the project" and "we removed Sesotho from it" the same event, and the
|
||
room's record of the first becomes unreadable once the second lands.
|
||
|
||
The cost is removal, and it is deferred to Phase 8 rather than solved now —
|
||
see [What this does not do](#what-this-does-not-do).
|
||
|
||
## The scope fork: one room, or several
|
||
|
||
This is the decision that changes the shape of the plan, and it should be made
|
||
before Phase 1 rather than discovered in Phase 5.
|
||
|
||
Every foreign key on every `Mantra*` entity today points inside one room. A
|
||
translation names an artifact version in its room; a chapter names a version in
|
||
its room; nothing names anything a member of this room might not hold. A project
|
||
is the first entity with an obvious reason to cross that line: a group translating
|
||
into Setswana may well want the Sesotho translation that another group produced
|
||
counted as part of the same project.
|
||
|
||
**Phase 1 through 7 assume one room.** `projectId`, `artifactVersionId` and
|
||
`translationArtifactVersionId` are real foreign keys with `onDelete = CASCADE`,
|
||
like every sibling. A proposal naming a version this room does not hold is
|
||
refused at apply time, which is the same refusal `MantraTranslationArtifactVersion`
|
||
already gets for free from its FK.
|
||
|
||
**Cross-room is Phase 9, and it is not a small change.** The precedent is
|
||
`parentChatRoomId` on `ChatRoom` and `GroupKeyState`, which is deliberately *not*
|
||
a foreign key: a subgroup member need never have been in the parent, so a
|
||
dangling value is the normal state rather than an error. A cross-room project
|
||
membership is in the same position, and so it needs:
|
||
|
||
- the FK dropped, and the apply arm's "do we hold it" check replaced with
|
||
something that stores the reference unresolved,
|
||
- a way to name a version in another room at all — a room id beside the event id,
|
||
since an event id alone is unresolvable,
|
||
- a UI that can show a member it cannot resolve, honestly, without looking broken,
|
||
- and an answer to what the group's signature means over a reference it cannot
|
||
check. It means the group asserts the membership; it does not vouch for the
|
||
thing named, which no amount of signing can.
|
||
|
||
Do not design Phases 1–7 to be cross-room-ready. The FK is load-bearing in Phase
|
||
1 (it is what makes a bad proposal fail loudly) and taking it out later is one
|
||
migration, which is cheaper than carrying an unverifiable column through seven
|
||
phases of a feature that does not use it.
|
||
|
||
## Phase 0 — the decisions, written down
|
||
|
||
No code. Settle, in this document:
|
||
|
||
- the three kind numbers, and the collision note described above;
|
||
- one-room or cross-room (recommended: one room, Phase 9 for the rest);
|
||
- that the creator is a `creator` tag of its own and the owners are
|
||
`ContributorTag`s with an owner role, rather than columns filled from local
|
||
knowledge, and that the owner set is never empty and defaults to the room's own
|
||
key;
|
||
- that `ROLE_OWNER` reaches `KNOWN_ROLES` in the same commit as the first event
|
||
that emits it — Phase 1's first line, and the one omission in this plan that
|
||
fails without a word anywhere;
|
||
- that campaign is out of scope entirely — no model, no column, no reader;
|
||
- **the schema version number.** The database is at v20 and the Curated fork
|
||
shares this history and allocates version numbers out of the same space — the
|
||
record of the last three pulls says v20 on both sides. Whoever lands first
|
||
takes 21; check the Curare clone before claiming it, because two trees that
|
||
both call their next migration 21 is a merge nobody can fix afterwards.
|
||
|
||
**This is the one decision the build got wrong, and it checked the wrong
|
||
tree.** Projects took 21 on their own branch while `mantra` took 21 for the
|
||
subgroup parentage table (`eba97333`), which is a manual `MIGRATION_20_21`
|
||
rather than an auto-migration. The collision cost nothing because neither had
|
||
shipped — projects renumbered to **22** on the way in — but the lesson is that
|
||
the sibling fork was not the only tree to check. Check the base branch.
|
||
|
||
## Phase 1 — three events and their tags
|
||
|
||
New, under `press.mantra.compose.nostr.nip30303`:
|
||
|
||
```
|
||
ProjectEvent.kt 30330
|
||
ProjectArtifactVersionEvent.kt 30331
|
||
ProjectTranslationArtifactVersionEvent.kt 30332
|
||
tags/ProjectIdTag.kt
|
||
tags/CreatorTag.kt "creator"
|
||
```
|
||
|
||
**First, and in this same commit**, `tags/ContributorTag.kt` learns the owner role:
|
||
|
||
```kotlin
|
||
const val ROLE_OWNER = "owner"
|
||
|
||
private val KNOWN_ROLES = setOf(
|
||
ROLE_TRANSLATOR, ROLE_PROOF_READER, ROLE_EDITOR, ROLE_OWNER,
|
||
)
|
||
```
|
||
|
||
Both halves. A role in the constants and not in the set parses back as `null`
|
||
while staying inside the signed event id, so the project lands with no owners and
|
||
nothing is logged — see [why that is
|
||
silent](#role_owner-has-to-reach-known_roles-in-the-same-commit). Extend the
|
||
comment above `KNOWN_ROLES` to say that the set has now diverged from NIP-F4's
|
||
host/cohost/editor by four rather than three.
|
||
|
||
No `ROLE_CREATOR`: the creator is not a contributor tag.
|
||
|
||
`CreatorTag` is `NameTag`'s shape with one extra check, since it holds a key
|
||
rather than free text:
|
||
|
||
```kotlin
|
||
class CreatorTag(val pubKey: HexKey) {
|
||
companion object {
|
||
const val TAG_NAME = "creator"
|
||
|
||
fun parse(tag: Array<String>): CreatorTag? {
|
||
ensure(tag.has(1)) { return null }
|
||
ensure(tag[0] == TAG_NAME) { return null }
|
||
ensure(tag[1].length == 64) { return null }
|
||
return CreatorTag(tag[1])
|
||
}
|
||
|
||
fun assemble(pubKey: HexKey): Array<String> = arrayOf(TAG_NAME, pubKey)
|
||
}
|
||
}
|
||
```
|
||
|
||
The length check is the one guard that matters: without it a malformed value
|
||
parses, lands on the row, and names nobody. `ContributorTag` has the same check for
|
||
the same reason, which is what makes an owner's key safe to take from a form.
|
||
|
||
`ProjectIdTag` is an `EventReference` tag, copied from `ArtifactIdTag` exactly —
|
||
the `ensure(tag.has(1))`, the `tag[1].length == 64`, the optional relay hint at
|
||
index 2 and pubkey at 3. `ArtifactVersionIdTag` and
|
||
`TranslationArtifactVersionIdTag` already exist and are what the two membership
|
||
events point with, so no other tag is new. `NameTag` already exists.
|
||
|
||
`ProjectEvent`:
|
||
|
||
```kotlin
|
||
const val KIND = 30330
|
||
const val ALT_DESCRIPTION = "Project"
|
||
|
||
fun name() = tags.firstNotNullOfOrNull(NameTag::parse)?.name
|
||
|
||
fun creator() = tags.firstNotNullOfOrNull(CreatorTag::parse)?.pubKey
|
||
|
||
fun owners() = tags.mapNotNull(ContributorTag::parse)
|
||
.filter { it.role == ContributorTag.ROLE_OWNER }
|
||
.map { it.pubKey }
|
||
|
||
fun build(
|
||
name: String,
|
||
description: String,
|
||
creatorPublicKey: HexKey,
|
||
ownerPublicKeys: Set<HexKey>,
|
||
createdAt: Long = TimeUtils.now(),
|
||
initializer: TagArrayBuilder<ProjectEvent>.() -> Unit = {},
|
||
) = eventTemplate(KIND, description, createdAt) {
|
||
require(ownerPublicKeys.isNotEmpty()) { "A project has at least one owner" }
|
||
|
||
alt(ALT_DESCRIPTION)
|
||
addUnique(NameTag.assemble(name))
|
||
addUnique(CreatorTag.assemble(creatorPublicKey))
|
||
// Sorted, because the id is a hash over the tags as emitted and a form hands
|
||
// its selection over in no particular order. addUniqueValueIfNew, not
|
||
// addUnique, which would keep one p-tag of the several.
|
||
ownerPublicKeys.sorted().forEach {
|
||
addUniqueValueIfNew(ContributorTag.assemble(it, ContributorTag.ROLE_OWNER))
|
||
}
|
||
initializer()
|
||
}
|
||
```
|
||
|
||
Content is the description. Name and creator are single-value tags, so `addUnique`
|
||
as every sibling does; the owners are a set, so `addUniqueValueIfNew` in sorted
|
||
order. `owners()` filters on the role rather than taking every `p` tag, which is
|
||
true today and one future role away from not being.
|
||
|
||
A `Set` rather than a `List`, and non-empty by contract: the caller always has an
|
||
answer, since the room's own key is the default, and an empty set would make "owned
|
||
by us" and "owner unstated" the same event.
|
||
|
||
Tag order is fixed and starts with `alt`, because the id is a hash over the tags and
|
||
anything that rebuilds this event has to emit them in the same order or produce an
|
||
event the signature does not cover. For the owners that includes the order among
|
||
themselves, which the sort fixes.
|
||
|
||
The two membership events carry two id tags and an empty content. `build` takes
|
||
both ids:
|
||
|
||
```kotlin
|
||
fun build(
|
||
projectId: HexKey,
|
||
artifactVersionId: HexKey,
|
||
createdAt: Long = TimeUtils.now(),
|
||
initializer: TagArrayBuilder<ProjectArtifactVersionEvent>.() -> Unit = {},
|
||
) = eventTemplate(KIND, "", createdAt) {
|
||
alt(ALT_DESCRIPTION)
|
||
addUnique(ProjectIdTag.assemble(projectId))
|
||
addUnique(ArtifactVersionIdTag.assemble(artifactVersionId))
|
||
initializer()
|
||
}
|
||
```
|
||
|
||
Note `TagArrayBuilder<ProjectArtifactVersionEvent>` — the real type. Three events
|
||
in this package take `TagArrayBuilder<ArtifactEvent>` by copy-paste, which is a
|
||
phantom generic that happens to compile; do not add a fourth.
|
||
|
||
**No creator tag on a membership**, though the server's schema has `creatorId` on
|
||
both join models. Two reasons. A membership is an act rather than a work — "this
|
||
version is part of this project" is a fact about the project, and who moved it
|
||
there is a fact about process, which the `FrostSigningSession` already records. And
|
||
a membership proposed in the founding batch has the same creator as the project it
|
||
arrives with, so the tag would be redundant in the common case and ambiguous in the
|
||
other, where it would be the first place in the app two different people are named
|
||
as the creator of one decision.
|
||
|
||
If it turns out to be wanted, the cost is a nullable column and one `add` — the
|
||
tag parses on an event that lacks it, and a row written without it reads back null.
|
||
Nothing in Phases 2 through 7 forecloses it.
|
||
|
||
Also in this phase, and the reason it is a phase of its own: a `membershipsOf`
|
||
companion on each membership event, the counterpart to
|
||
`ArtifactVersionEvent.initialVersionOf`. It takes the signed `project` event and
|
||
the list of version ids the form collected, and returns the templates — borrowing
|
||
the project's own `createdAt`, so a project and its first contents read as one act
|
||
rather than four events that share a session.
|
||
|
||
**Tests** (`commonTest`):
|
||
|
||
- a `RumorIdAgreementTest` case per kind, in the shape the five existing ones have
|
||
— build the template, hash it the way `MantraDao.rumorOf` does, and assert the
|
||
row's id agrees;
|
||
- a `ContributorTag` case asserting `ROLE_OWNER` survives `parse`. **This is the
|
||
test that catches the silent drop**, it is three lines, and without it the only
|
||
symptom is projects with no owners;
|
||
- a `CreatorTag` case: a well-formed value parses, a value that is not 64
|
||
characters does not;
|
||
- a `ProjectEvent` case with **three owners**, asserting all three come back and in
|
||
sorted order — the failure mode of `addUnique` is one owner, which looks like a
|
||
form bug rather than a builder bug;
|
||
- a `ProjectEvent` case where the creator is also an owner, asserting `creator()`
|
||
and `owners()` both name them. The two live on different tags now, so this is a
|
||
regression test against anyone folding them back together;
|
||
- a `ProjectEvent` case asserting `build` rejects an empty owner set.
|
||
|
||
## Phase 2 — three rows, and schema 21 → 22
|
||
|
||
New, under `press.mantra.compose.database.model`:
|
||
|
||
```
|
||
MantraProject.kt
|
||
MantraProjectArtifactVersion.kt
|
||
MantraProjectTranslationArtifactVersion.kt
|
||
```
|
||
|
||
`MantraProject` is `MantraArtifact`'s shape with fewer fields:
|
||
|
||
```kotlin
|
||
@Entity(
|
||
foreignKeys = [
|
||
ForeignKey(
|
||
entity = ChatRoom::class,
|
||
parentColumns = ["id"], childColumns = ["chatRoomId"],
|
||
onDelete = ForeignKey.CASCADE,
|
||
),
|
||
],
|
||
)
|
||
data class MantraProject(
|
||
@PrimaryKey val id: HexKey,
|
||
val publicKey: HexKey, // the room's key on a signed row
|
||
val name: String,
|
||
val description: String,
|
||
val creatorPublicKey: HexKey, // off the creator tag, not local knowledge
|
||
/** The owner p-tags, comma-joined and sorted. See [ownerPublicKeySet]. */
|
||
val ownerPublicKeys: String,
|
||
val chatRoomId: String,
|
||
val signature: String, // empty for a rumor
|
||
val relayHint: String? = null,
|
||
val marmotGroupEventId: HexKey? = null,
|
||
val groupSignedEventId: HexKey? = null,
|
||
override val nostrEventId: HexKey? = null,
|
||
override val createdAt: Instant = Clock.System.now(),
|
||
override val updatedAt: Instant = createdAt,
|
||
): OptionalNostrEventEntity, TimestampedEntity
|
||
```
|
||
|
||
with `fromProjectEvent(event, chatRoomId)` and
|
||
`fromProjectEventTemplate(template, chatRoomId, userPublicKey)`, the latter
|
||
hashing the id through `EventHasher.hashId` so the row on disk and the payload on
|
||
the wire are one event.
|
||
|
||
`creatorPublicKey` and `ownerPublicKeys` are non-null and come **only** from
|
||
`event.creator()` and `event.owners()`. `fromProjectEvent` returns null when the
|
||
creator is missing or the owner list is empty, in the `?.let` chain
|
||
`MantraArtifact.fromArtifactEvent` already uses for its four required fields — an
|
||
event missing something it was built to carry has either been truncated or is not
|
||
what it claims, and a row that fills the gap from whatever this device happens to
|
||
know is the failure the tag exists to prevent.
|
||
|
||
Never default `creatorPublicKey` to the submitter and never default the owners to
|
||
`chatRoomId`; the room's key is the *form's* default, which is a different thing
|
||
and belongs in Phase 7. An empty owner list here is the signature of `ROLE_OWNER`
|
||
missing from `KNOWN_ROLES`, so the refusal is also the alarm for that bug — which
|
||
is the one argument for refusing rather than tolerating it.
|
||
|
||
**Owners are one column, not a fourth table.** `DkgSession.participantPublicKeys`
|
||
is the precedent: a comma-joined, sorted string with a set accessor beside it, and
|
||
the reason it sorts — "so two devices that assembled the set in different orders
|
||
store the same string" — holds here too.
|
||
|
||
```kotlin
|
||
/** The project's owners, or an empty set on a row that somehow has none. */
|
||
fun ownerPublicKeySet(): Set<HexKey> = ownerPublicKeys
|
||
.split(",")
|
||
.mapNotNull { key -> key.trim().takeIf { it.isNotEmpty() } }
|
||
.toSet()
|
||
```
|
||
|
||
A join table would buy querying by owner, which no screen does — Phase 6 says why
|
||
— and cost a table, a DAO, a cascade and three more lines of apply. Revisit it
|
||
when something needs "every project this key owns" across rooms, which is Phase 9's
|
||
territory anyway.
|
||
|
||
The membership rows add the two foreign keys and nothing else. Both get an index
|
||
on `projectId`, because every read of them is "this project's contents" — the same
|
||
reason v16 added one on `ChatMessage.chatRoomId`.
|
||
|
||
**No `toProjectEvent()`.** The eight `toXEvent()` methods exist for one purpose:
|
||
rebuilding events for a chronicle in rooms whose work predates `GroupSignedEvent`
|
||
(schema v13). They are deprecated, there is a removal checklist in
|
||
`docs/member-chronicle.md`, and each one drags a case of
|
||
`ChronicleRoundTripTest` behind it. Projects post-date v13 by seven schema
|
||
versions, so every project this app will ever hold is on file as a signed event
|
||
and `signedEventsOf` finds it. Adding three more rebuilds would be adding to a
|
||
mechanism being retired, to serve rows that cannot exist.
|
||
|
||
**Schema.** Three tables, nothing existing changes shape, so this is an
|
||
`AutoMigration(from = 21, to = 22)` entry with a comment in the style of the other
|
||
nineteen, plus three entities in `@Database`, three `abstract fun`s on
|
||
`MantraDatabase`, and three DAOs.
|
||
|
||
The DAOs are `MantraDialectDao`'s shape — an interface, `@Upsert`, and the queries
|
||
each screen needs:
|
||
|
||
```kotlin
|
||
@Query("SELECT * FROM MantraProject WHERE chatRoomId = :chatRoomId ORDER BY name")
|
||
suspend fun getProjectsByChatRoomId(chatRoomId: String): List<MantraProject>
|
||
|
||
@Query("SELECT * FROM MantraProject WHERE id = :id")
|
||
suspend fun getProjectById(id: String): MantraProject?
|
||
```
|
||
|
||
and, on the membership DAOs, `getByProjectId` and the inverse
|
||
`getProjectsByArtifactVersionId` — the inverse is what lets an artifact's own
|
||
screen say which projects it is part of, in Phase 7.
|
||
|
||
**Tests** (`jvmTest`): a `MantraProjectDaoJvmTest` in the shape of
|
||
`DkgSessionDaoJvmTest` — real in-memory Room over `BundledSQLiteDriver` — covering
|
||
upsert, the by-room query, and that a membership naming an absent version is
|
||
refused by the FK. That last one is the Phase 1 scope decision made testable.
|
||
|
||
## Phase 3 — applying a signed project
|
||
|
||
Three arms in `ChatMessage.applyInnerEvent`, which is where every nip30303 kind
|
||
lands whether it arrived over the wire or was signed locally — `FrostSigningManager`
|
||
reuses this dispatch rather than repeating it.
|
||
|
||
`ProjectEvent.KIND` writes the row and a chat line:
|
||
|
||
```kotlin
|
||
ProjectEvent.KIND -> {
|
||
MantraProject.fromProjectEvent(
|
||
projectEvent = ProjectEvent(event.id, event.pubKey, event.createdAt,
|
||
event.tags, event.content, event.sig),
|
||
chatRoomId = groupId,
|
||
)?.let { project ->
|
||
database.mantraProjectDao().upsert(
|
||
project.copy(
|
||
marmotGroupEventId = marmotGroupEventId,
|
||
groupSignedEventId = groupSignedEventId,
|
||
)
|
||
)
|
||
|
||
ChatMessage(
|
||
giftWrapPayloadId = null,
|
||
messageType = "project",
|
||
marmotGroupEventId = marmotGroupEventId,
|
||
marmotInnerEventId = marmotInnerEventId,
|
||
senderPublicKey = senderPublicKey,
|
||
isUserMessage = isUserMessage,
|
||
chatRoomId = groupId,
|
||
createdAt = createdAt,
|
||
content = "Started the ${project.name} project",
|
||
)
|
||
|
||
// The line names the project and not its creator. `senderPublicKey` is
|
||
// the room's coordinator on a group-signed row, so a line saying "X
|
||
// started" would be the transcript's own guess; the creator and the
|
||
// owners are on the row, and the detail screen is where they are shown.
|
||
}
|
||
}
|
||
```
|
||
|
||
The two membership arms write their row and **return null** — no line of their
|
||
own. This is the convention the whole family follows: a version writes none
|
||
beside its artifact, a chunk none beside its chapter, because they arrive as the
|
||
later items of a batch whose lead has already said what happened. A membership
|
||
added on its own, later, is not silent either: `FrostSigningManager.announce`
|
||
already writes "The group signed …" when a session completes, which is the line
|
||
that covers it. Three new strings for a transcript the signing session is already
|
||
narrating would be three places for the same event to be described differently.
|
||
|
||
**Both membership arms let the foreign key violation out, and must.** The
|
||
temptation is to catch it — `applySignedEvent` swallows throwables so a succeeded
|
||
session is not reported as failed — and that is what was built first. It was wrong
|
||
twice over. `applySignedEvent` already has its own try/catch, so the reasoning was
|
||
satisfied without doing anything; and `ChronicleManager.applyPage` learns a
|
||
payload could not be applied **only** by catching the exception, counts it
|
||
`failed`, and `sweep` re-runs the pages until failures stop falling. That loop is
|
||
the entire mechanism for a membership whose project arrived in a later page.
|
||
Swallowing it counts the payload as applied, the sweep terminates, and the row is
|
||
lost silently on the one device with nothing to fall back on.
|
||
|
||
No sibling arm catches either — a translation chapter naming an absent chapter
|
||
throws today — so catching made this the only kind a chronicle could not retry.
|
||
|
||
**Tests** (`jvmTest`): a `ProjectApplyJvmTest` in the shape of
|
||
`TranslationChunkApplyJvmTest` — apply a project with three owners and assert
|
||
`creatorPublicKey` and `ownerPublicKeySet()` are what the event carried; apply a
|
||
project event with the `creator` tag stripped and assert **no row is written**;
|
||
apply one whose owner tags all carry an unknown role and assert the same, since
|
||
that is what a missing `KNOWN_ROLES` entry looks like from here; apply a
|
||
membership, assert the row; apply a membership whose version is absent and assert
|
||
the project survives.
|
||
|
||
## Phase 4 — asking the group
|
||
|
||
`ProposedEvent.summarize` gets three branches, or members are asked to sign
|
||
"Event of kind 30330".
|
||
|
||
```kotlin
|
||
ProjectEvent.KIND -> Summary(
|
||
label = "New project",
|
||
detail = ProjectEvent(/* … */).let { project ->
|
||
listOfNotNull(
|
||
project.name(),
|
||
event.content.takeIf { it.isNotBlank() },
|
||
// Shown because signing this is what makes the group's attestation
|
||
// that X started it. A member who cannot see the claim cannot be
|
||
// said to have agreed to it -- see the creator note above.
|
||
project.creator()?.take(12)?.let { "started by $it" },
|
||
// Only the owners that are not this room. "owned by us" is the
|
||
// common case and saying it every time trains members not to read
|
||
// the line that matters when somebody else is on it.
|
||
project.owners()
|
||
.filter { it != localChatRoom.chatRoom.id }
|
||
.takeIf { it.isNotEmpty() }
|
||
?.let { others ->
|
||
"owned by " + others.joinToString(", ") { it.take(12) }
|
||
},
|
||
).joinToString(" · ")
|
||
}
|
||
)
|
||
```
|
||
|
||
The owner line needs the room's id, which `summarize` does not have: it is a pure
|
||
function over one event, by design, so that the signing screen and the proposal
|
||
list cannot drift into two names for one thing. Two ways out, and they are not
|
||
equally good. Threading the room id through `summarize` is three call sites and no
|
||
new concept, but it is the start of passing context into a function whose value is
|
||
that it has none. Omitting the comparison and listing every owner is honest and,
|
||
with the room always among them, noisy on every single project.
|
||
|
||
Take the first, and take it *as* the resolver this phase already needs — the two
|
||
problems are one problem. A `summarize(event, resolve)` where `resolve` turns a
|
||
pubkey or an event id into a name answers the owner question, the creator question
|
||
(`started by a4f1be55d0c3` is barely better than nothing; `started by Kgothatso`
|
||
is the point) and the membership question below, all in one change. Both call
|
||
sites have a database behind them. Design it once, here, rather than three times.
|
||
|
||
The two membership branches are the honest problem in this phase. `summarize` is a
|
||
pure function over one event with no database — deliberately, so that the signing
|
||
screen and the room's proposal list cannot drift into two names for one thing —
|
||
and a membership event carries two 64-character ids and nothing else. The best it
|
||
can do is:
|
||
|
||
```kotlin
|
||
ProjectArtifactVersionEvent.KIND -> Summary(
|
||
label = "A source version joins the project",
|
||
detail = ProjectArtifactVersionEvent(/* … */)
|
||
.artifactVersionId()?.take(12) ?: "Unknown version"
|
||
)
|
||
```
|
||
|
||
which is a member being asked to sign a truncated hash. It is tolerable **only**
|
||
because of the batch: a project and its first contents are proposed together, the
|
||
project's own line names the project and says what it is, and the memberships read
|
||
as its continuation — the same way "Version of the artifact" reads as the second
|
||
line of a batch of two.
|
||
|
||
It stops being tolerable in Phase 7's "add an existing version to an existing
|
||
project", where the membership is the whole of the proposal — "sign a4f1be55d0c3"
|
||
is a request no member can evaluate. The resolver described above is what fixes
|
||
this one too, and it is why that design belongs in this phase rather than a later
|
||
one: three summaries want the same lookup, and the owner comparison wants it on a
|
||
line where getting it wrong is a member agreeing to something they could not read.
|
||
|
||
**Tests** (`commonTest`): `ProposedEventTest` cases per branch, plus one asserting
|
||
a project whose only owner *is* the room says nothing about owners, and one with
|
||
an outside co-owner that names them and not the room. The second is the case the
|
||
plural exists for, and the one a member most needs to read before signing.
|
||
|
||
## Phase 5 — the chronicle
|
||
|
||
Three entries appended to `ChronicleEvent.APPLY_ORDER`:
|
||
|
||
```kotlin
|
||
private val APPLY_ORDER: List<Kind> = listOf(
|
||
DialectEvent.KIND,
|
||
ArtifactEvent.KIND,
|
||
ArtifactVersionEvent.KIND,
|
||
ChapterEvent.KIND,
|
||
ChunkEvent.KIND,
|
||
TranslationArtifactVersionEvent.KIND,
|
||
TranslationChapterEvent.KIND,
|
||
TranslationChunkEvent.KIND,
|
||
ProjectEvent.KIND, // new
|
||
ProjectArtifactVersionEvent.KIND, // new
|
||
ProjectTranslationArtifactVersionEvent.KIND, // new
|
||
)
|
||
```
|
||
|
||
Order is the whole content of this phase. `APPLY_ORDER` is dependency order and a
|
||
page applies front to back, so a membership must sort after *both* the project and
|
||
the version it names. Appending gets that right: the memberships are last, and
|
||
everything they can name is already in the list. `CHRONICLABLE_KINDS`,
|
||
`applyRank`, `inApplyOrder` and `isChroniclable` are all derived from this one
|
||
list and need no change.
|
||
|
||
Projects are chroniclable where the contributor lists are not, and for the
|
||
opposite of their reason: these have an apply arm that writes a row, and they are
|
||
signed rather than submitted, so a receiver can check them. A late member who
|
||
gets a room's chronicle and not its projects gets the whole library and no account
|
||
of what any of it is for.
|
||
|
||
`rebuiltEventsOf` — the deprecated walk — is **not** touched, per Phase 2.
|
||
|
||
**Tests** (`commonTest` / `jvmTest`): a `ChronicleEventTest` case asserting the
|
||
three ranks, specifically that each membership outranks the project and the
|
||
version kind it names; a `ChronicleAssemblyJvmTest` case asserting a room with a
|
||
project chronicles it.
|
||
|
||
## Phase 6 — the repository
|
||
|
||
`MantraRepository` gains, with `DatabaseMantraRepository` implementing and `NO_OP`
|
||
returning empty:
|
||
|
||
```kotlin
|
||
suspend fun getProjects(chatRoomId: String): List<MantraProject>
|
||
suspend fun getProject(id: String): MantraProject?
|
||
suspend fun getProjectArtifactVersions(projectId: String): List<MantraArtifactVersion>
|
||
suspend fun getProjectTranslationArtifactVersions(projectId: String): List<MantraTranslationArtifactVersion>
|
||
suspend fun getProjectsForArtifactVersion(artifactVersionId: String): List<MantraProject>
|
||
```
|
||
|
||
No query by creator or owner. Both are columns on the row and a screen that wants
|
||
them has the row; a `getProjectsOwnedBy` would be a query with one caller and an
|
||
index to go with it, and the room's project list is short. Add it when a screen
|
||
asks, not before.
|
||
|
||
The two contents queries return the *versions*, not the join rows: no screen wants
|
||
a join row, and resolving it in the repository is one query there instead of an N+1
|
||
in a view model. Room does this with a join; both are indexed by Phase 2.
|
||
|
||
**Nothing goes in `MantraDao`.** Its `addDialect` / `addArtifactVersion` /
|
||
`rumorOf` / `submitToGroup` path is the old submit flow, reached now only from
|
||
`MantraDaoJvmTest`, and every entity a member creates is proposed for signing
|
||
instead. An `addProject` there would be a second way to create a project that
|
||
bypasses the quorum, which is the thing this feature is for.
|
||
|
||
## Phase 7 — the screens
|
||
|
||
Five files per screen, in the shape every screen in this app has: a
|
||
`@Serializable data class` route, a sealed `UIState` (Loading / Error / Loaded), a
|
||
view model with a `factory`, the composable, and a `composable<Route>` entry in
|
||
`MantraNavHost`.
|
||
|
||
**`ChatRoomDetailScreen` gets a projects section**, between the library and the
|
||
dialects, copying the artifacts section exactly: a `labelMedium` heading, an empty
|
||
line when there are none, a `Card` + `ListItem` per project navigating to
|
||
`ProjectDetailRoute`, and a `TextButton` to `AddProjectRoute`.
|
||
`ChatRoomDetailUIState.Loaded` gains `projects: List<MantraProject> = emptyList()`
|
||
and the view model's `initiate` loads them.
|
||
|
||
**`AddProjectScreen` / `AddProjectViewModel` / `AddProjectRoute`.** Name and
|
||
description fields, a multi-select of the room's artifact versions and a
|
||
multi-select of its translations, an owner, and one primary action. The view model
|
||
is `AddArtifactViewModel`'s shape, including the parts that are not obvious:
|
||
|
||
- the **creator is `activeUserPublicKey`**, not a field. It is who is filling the
|
||
form in, the view model already has it, and a picker would only let somebody
|
||
credit the wrong person;
|
||
- the **owners are a list, pre-filled with the room's own id** and labelled as this
|
||
group, with an add control and a remove on each. Most projects never touch it,
|
||
and that is the point of pre-filling rather than hiding: a group that *is* making
|
||
something for somebody else needs the question asked, and a group that is not
|
||
needs to see the answer it is giving. Each entry takes a pubkey or an npub;
|
||
validate to 64 hex on entry, since `ContributorTag.parse` drops a tag whose value
|
||
is not that length and the owner would vanish between the form and the signature.
|
||
The list cannot be emptied — `build` requires one and the last remove is
|
||
disabled rather than allowed and then refused;
|
||
- `isActionPending`, guarding the double tap;
|
||
- `canSign` on `UIState.Loaded` from `frostSigningRepository.canSign`, with the
|
||
FAB borrowing `ButtonDefaults.buttonColors().disabled*` and
|
||
`Modifier.semantics { disabled() }` when it cannot;
|
||
- `proposeSigningBatch(lead = projectTemplate, dependents = …)`, where
|
||
`dependents` reads the signed project's id and returns both membership lists.
|
||
The project's id is a hash over the group's key at the room's path, which this
|
||
screen does not know and should not;
|
||
- on success, navigate to `FrostSigningRoute(session.id)` with
|
||
`popUpTo<AddProjectRoute> { inclusive = true }`.
|
||
|
||
A project with no contents is allowed — unlike an artifact with no version, which
|
||
is inert because nothing can attach to it, an empty project is a name waiting for
|
||
work and Phase 7's second flow is how work reaches it.
|
||
|
||
**`ProjectDetailScreen` / `ProjectDetailViewModel` / `ProjectDetailRoute`.** Name,
|
||
description, creator, owner, the source versions, the translations, and two add
|
||
buttons. Lists load once in `initiate()`; to reflect a just-signed change, navigate
|
||
to a fresh `ProjectDetailRoute` with `popUpTo<ThatRoute> { inclusive = true }`.
|
||
`DetailRow` is the shared label/value widget.
|
||
|
||
Creator is a `DetailRow` and the owners are a short list, both showing the
|
||
**profile** rather than the hex — resolve through `ProfileDao` and fall back to a
|
||
truncated npub, which is what the rest of the app does with a key it has no profile
|
||
for. An owner entry that is the room's own key reads "this group" rather than
|
||
printing the room id back at a member standing inside it, and sorts first, so the
|
||
common single-owner case is one unremarkable line.
|
||
|
||
**`AddToProjectScreen`** — pick a version, propose one membership. **This is the
|
||
flow that needs Phase 4's resolver**, because here the membership is the entire
|
||
proposal and a truncated hash is not a thing to sign.
|
||
|
||
Each screen obeys `CLAUDE.md`, and the audit is not optional —
|
||
`docs/scripts/m3-audit.sh --check` runs inside `./gradlew check` and fails on a
|
||
budget exceeded:
|
||
|
||
- spacing from `MaterialTheme.spacing`, never a `.dp` literal in a spacing
|
||
position (budget 0);
|
||
- colour from a role, never a `Color(0x…)` and effectively never `.copy(alpha=)`
|
||
(budget 0 outside `ui/theme/`);
|
||
- every string in `composeResources/values/strings.xml`, **sentence case** —
|
||
"Add project", "No projects in this group", not "Add Project" (budget 0
|
||
title-case strings);
|
||
- `NavigateBackButton(onNavigateBack)` in the app bar of all four, since all four
|
||
are pushed and the desktop target has neither a system back nor an edge swipe;
|
||
- `Modifier.padding(innerPadding).readableContent()` on each content root;
|
||
- all four states said, with `ErrorState(onRetry = …)` and `EmptyState`'s message
|
||
given explicitly;
|
||
- `ScreenStateTransition(viewModel.uiState) { … }` wrapping the state `when`, and
|
||
only where the `when` is the composable's whole body;
|
||
- icons with a decided `contentDescription` — a real one, or `Decorative` where
|
||
the label is beside it, never a bare `null`.
|
||
|
||
Around twenty new strings — the six beyond the screens themselves are the owners
|
||
field's label, its "this group" value, its add and remove actions, its validation
|
||
error, and the creator row's label. Check `grep -c "<string"` before and after; the
|
||
catalogue is at 460.
|
||
|
||
## Phase 8 — removing something from a project
|
||
|
||
An append-only membership log has no way to say "not any more", and this is the
|
||
cost of the Phase 2 shape, paid here rather than designed around.
|
||
|
||
Two options, and they should be weighed with the schema in front of you rather
|
||
than now. A **revocation event** (30333) naming the membership it ends, which is
|
||
how a signed log normally retracts and which keeps the room's record of both
|
||
decisions — at the price of a fourth kind, a fourth arm, a fourth chronicle rank,
|
||
and every read of a project's contents becoming a left-join against revocations.
|
||
Or **supersession**, the mechanism `TranslationChunkEvent` already uses: a new
|
||
event replaces an old one and the apply arm drops what it replaced, with the
|
||
per-kind replace/dedupe rule living in `applyInnerEvent`. That is cheaper and
|
||
fits a *correction* better than a *removal*; there is no "nothing" for a
|
||
membership to be superseded by.
|
||
|
||
Lean toward revocation, and note that the chronicle has a stake in whichever is
|
||
chosen: `TranslationChunkEvent`'s supersession means a chronicle carries the
|
||
group's current answer rather than its drafts, and a revocation means a chronicle
|
||
has to carry the revocation too or a late member sees a membership the group
|
||
ended.
|
||
|
||
## Phase 9 — projects across rooms
|
||
|
||
Described under [the scope fork](#the-scope-fork-one-room-or-several). One
|
||
migration dropping two foreign keys, a room id beside each referenced event id, an
|
||
apply arm that stores what it cannot resolve, and a UI that can show an unresolved
|
||
member without looking broken. Follow `parentChatRoomId`'s precedent — a plain
|
||
column, never a foreign key, because a dangling value is the normal state.
|
||
|
||
## Verifying
|
||
|
||
```bash
|
||
ANDROID_HOME=/home/sigidli/Android/Sdk ./gradlew :composeApp:compileDebugKotlinAndroid
|
||
```
|
||
|
||
```bash
|
||
./gradlew :composeApp:jvmTest :composeApp:m3Audit
|
||
```
|
||
|
||
Room-backed tests go in `composeApp/src/jvmTest`, not `commonTest` — the android
|
||
unit-test variant's `PlatformDatabaseBuilder` needs a real `Context` and there is
|
||
no Robolectric. In a fresh worktree, bootstrap the four nested submodules first.
|
||
|
||
## Rollout
|
||
|
||
Phases 0–6 are invisible: new kinds nothing proposes, new tables nothing writes,
|
||
new arms nothing reaches. They can land in any order after 0, and each compiles
|
||
and tests on its own.
|
||
|
||
Phase 7 is what turns the feature on, and it is one-way in the sense that
|
||
matters: once a group has signed a project, every member's device must understand
|
||
kind 30330 to make sense of its room. A member on an older build sees three
|
||
unrecognised kinds — no row, no chat line, and `GroupSignedEvent` keeps the event,
|
||
so the row appears when they upgrade. That is the same degradation every kind in
|
||
this family has had, and it is why the inbound half ships before the screens that
|
||
produce anything.
|
||
|
||
Phase 8 should land close behind 7. A group that can add and cannot remove will
|
||
want to remove within a week.
|
||
|
||
## What this does not do
|
||
|
||
- **Funding.** No campaign, no pledge, no satoshis, and no column held open for
|
||
one. It is a feature with its own kinds and it needs projects to exist first.
|
||
- **Proof of authorship.** The creator tag is the group attesting to a claim the
|
||
proposer wrote; the creator does not sign anything. The `FrostSigningSession`
|
||
records who actually acted, and the two can disagree — which is a fact worth
|
||
having, not a defect to reconcile.
|
||
- **An owner that is not a key.** The server's `owner` → `ownerEntity` → `entity`
|
||
chain can name an organization with members, an email and a type. A set of
|
||
pubkeys carries none of that. If an owner needs to be a described thing rather
|
||
than an addressable one, that is an entity feature and it is not this.
|
||
- **Changing the owners.** They are tags on the project event, so the owner set is
|
||
fixed at the quorum that signed it. Editing it means a new project event
|
||
superseding the old, which is the same question Phase 8 asks about memberships
|
||
and should be answered with it rather than separately.
|
||
- **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
|
||
project is a grouping; what a group says *about* the grouping is later work.
|