diff --git a/docs/README.md b/docs/README.md index 933e8da5..0e94825d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,6 +21,7 @@ silent, or a decision that looked arbitrary and was not. | [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 | | [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 | +| [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 | | [curated-to-mantra-profiles.md](./curated-to-mantra-profiles.md) | the second pull: the fork's several-profiles line, ten commits and its first schema migration — which of it is a fix Mantra has today, who allocates a schema version number when two trees share one history, and the one conflict, which was Mantra's own | @@ -72,3 +73,10 @@ smallest of the plans: it changes the entrance to the direct-message flow and nothing past it, so read it with `StartDirectMessageToNpubOrNip05Dialog` and `ChatRoomMessagingViewModel.initiateNewChat` open, its four decisions before its four phases, and its table of where the build chose differently first. +The projects note is a phased plan that has not been built, and is the only one +whose starting point is outside this repository: the platform's server schema, +`mantra-db-models`, has had the three models for years. It reads as the +translation domain's unanswered question — the app can say what a chapter is and +who translated it, and cannot say what any of it is for — so read it after the +nip30303 conventions are familiar, its decisions before its phases, and its scope +fork before either, because that one changes the shape of the rest. diff --git a/docs/project-feature.md b/docs/project-feature.md new file mode 100644 index 00000000..07387781 --- /dev/null +++ b/docs/project-feature.md @@ -0,0 +1,1038 @@ +# Projects + +A phased plan, not yet built. + +`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", ""]` 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): TagArrayBuilder { + 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. + +## 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): 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 = 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, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> 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.() -> Unit = {}, +) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + addUnique(ProjectIdTag.assemble(projectId)) + addUnique(ArtifactVersionIdTag.assemble(artifactVersionId)) + initializer() +} +``` + +Note `TagArrayBuilder` — the real type. Three events +in this package take `TagArrayBuilder` 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 20 → 21 + +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 = 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 = 20, to = 21)` 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 + +@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 must tolerate the version not being here. The FK will refuse +the insert; catch it, log it, and return null rather than letting it escape — +`applySignedEvent` swallows throwables for a reason (the signature is valid +whether or not this device could write the row down) but a refusal that reaches it +is reported as a failed apply on a session that succeeded. + +**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 = 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 +suspend fun getProject(id: String): MantraProject? +suspend fun getProjectArtifactVersions(projectId: String): List +suspend fun getProjectTranslationArtifactVersions(projectId: String): List +suspend fun getProjectsForArtifactVersion(artifactVersionId: String): List +``` + +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` 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 = 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 { 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 { 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 "