From de4f15794a472a550dbd226176c64ed55b2754c5 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 18:55:23 +0200 Subject: [PATCH] docs: a phased plan for projects, and the four keys it has to re-answer The platform's server schema has had `project` and its two join models since before this app existed. Bringing them here is not a port: the server answers "whose project is this" out of a table of accounts, and this app is a group with no accounts, no server and no authority but a quorum of its own members. So every foreign key on `project` is re-answered. The creator becomes a `creator` tag and the owners a set of `p` tags with an owner role -- written into the template before it is proposed, so they are inside the id the quorum signs rather than one device's note about who inserted a row. The owner of record is still structurally the room, and `chatRoomId` carries that; the tags are what let a project be the group's work and somebody else's property. Campaign is out of scope entirely. Two things the plan is mostly about, because neither is recoverable from the code. The nip30303 kind band is not merely full -- 30310, 30311 and 30312 are double-booked with the ceremony family, survivable only because none of the three nip30303 kinds has a producer -- so projects take 30330-30332 and the collision gets written down. And `ContributorTag.parse` drops a role that is not in `KNOWN_ROLES`: 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. `ROLE_OWNER` reaching that set is Phase 1's first line, and three tests point at it. Ten phases, 0-7 to build and 8-9 deferred. The one decision to make before Phase 1 is the scope fork: a project's contents are same-room, with real foreign keys, because the FK is what makes a bad proposal fail loudly. Cross-room is Phase 9 and follows `parentChatRoomId`'s precedent -- a plain column, never a foreign key, because a dangling value is the normal state. Co-Authored-By: Claude Opus 5 --- docs/README.md | 8 + docs/project-feature.md | 1038 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 1046 insertions(+) create mode 100644 docs/project-feature.md 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 "