Files
mantra-kmp/docs/project-feature.md
Kgothatso Ngako aceef313ed docs: record what building projects changed about the plan
Five places the build chose differently, and one thing the plan did not
anticipate: a project reaches a chronicle only off the GroupSignedEvent record,
because the deprecated rebuild has no `toProjectEvent` and deliberately never
will. `recordSignedEvents` catches its own failure so a ceremony that succeeded
is not reported as failed, which leaves a narrow window where the row exists and
the signed event does not -- a project this device can show and can never hand
on. There is a test holding that case open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 19:50:41 +02:00

53 KiB
Raw Blame History

Projects

Phases 0 through 7 are built. Phases 8 and 9 -- removing something from a project, and projects that span rooms -- are not, and the reasoning for both is below rather than anywhere else.

Where the build chose differently, each of which is explained in the phase it belongs to:

the plan said the build did why
a creator tag and an owner tag a creator tag, and owners as ContributorTags with ROLE_OWNER a project has several owners, which is the cardinality ContributorTag is for
the room's project section is new it replaced a stub ChatRoomDetailScreen already had a heading, a permanent empty line and an ImplementationPendingRoute, under a // TODO: Add projects...
the summarize resolver is Phase 4 the seam is Phase 4, the resolution is Phase 7c two of its four call sites are composables and cannot await a database, so the names are pre-resolved in FrostSigningViewModel
three chronicle tests to add three to add and four to fix the existing suite compares a seeded room against CHRONICLABLE_KINDS itself, and one test asserts every chroniclable kind can also be rebuilt -- which projects deliberately cannot
around twenty new strings eighteen, three of them already there projects, no_projects_exists_in_this_group and create_project came with the stub

The one thing found while building that the plan did not anticipate: a project reaches a chronicle only off the GroupSignedEvent record, because there is no toProjectEvent rebuild. recordSignedEvents catches its own failure so that a ceremony that succeeded is not reported as failed, which leaves a narrow window where a project row exists and its signed event does not -- a project this device can show and can never hand on. ChronicleAssemblyJvmTest.a project is only ever chronicled off the record holds that case open rather than letting it be discovered later.

mantra-db-models — the platform's server-side Sequelize schema — has had three models waiting for this app since before it existed: project, with a name and a description, and the two join models that give it its contents, projectArtifactVersion and projectTranslationArtifactVersion. A project is how the platform says this body of work: a set of source versions to translate from and a set of translations being produced against them, grouped under one name so that funding, progress and membership have something to attach to.

This app has the whole tree underneath a project already — eight nip30303 kinds from Artifact (30300) down to TranslationChunk (30309), each one an event the group signs with its own threshold key and a Room row derived from it. What it has no way to say is that two of those translations and three of those source versions are one piece of work.

The server's schema is a relational description of a platform with accounts. This app is a Marmot/MLS group with no accounts, no server and no authority but a quorum of its own members. Every foreign key on project therefore has to be re-answered, and saying how is most of the work this document does: one becomes a tag of its own, one becomes a set of tags, one becomes the room, and one is out of scope. The code to write is comparatively mechanical — the same eight-file shape every nip30303 entity already has, three times over — and the one place it goes quietly wrong is a role the parser has not been told about.

What the server's schema says, and what survives

project
  id            string, nanoid
  name          string, not null
  description   text,   not null
  creatorId     -> user,     not null
  ownerId       -> owner,    not null, UNIQUE
  campaign      hasOne campaign
  hasMany projectArtifactVersion
  hasMany projectTranslationArtifactVersion

projectArtifactVersion              projectTranslationArtifactVersion
  id                                  id
  creatorId -> user,            NN    creatorId -> user,                      NN
  projectId -> project,         NN    projectId -> project,                   NN
  artifactVersionId
            -> artifactVersion, NN              -> translationArtifactVersion, NN

Taking those fields one at a time:

name and description survive as the substance of the thing. By this family's convention the long text goes in the event's content and the short label gets a tag of its own — ArtifactEvent puts the name in content because an artifact has no longer field, ChapterEvent puts originalText there and the name in a NameTag. A project has both, so it follows the chapter: content is the description, and NameTag carries the name.

creatorId survives as a CreatorTag on the project event, and the difference between that and a column is the whole point. Every nip30303 entity a member creates is now proposed for FROST signing rather than submitted, so the row that lands is authored by the room's own key and carries no trace of who typed it: publicKey is the room's id, and both Marmot columns are null on a group-signed row. A creatorPublicKey column filled in from local knowledge would be a field the signed event does not carry — which, under a signature, is the one kind of mistake that is expensive. A ["creator", "<hex>"] tag written into the template before it is proposed is not that: it is inside the id the quorum signs, so what lands is the group saying "this project was started by X" rather than one device's note about who happened to insert a row.

That is a stronger statement than the server's column, and it is worth being clear about what kind of statement it is. The creator does not sign the project event — the room does — so this is a group attestation, not proof of authorship. A member could propose a project crediting somebody else, and a quorum that does not read the proposal would sign it. The mitigation is not cryptographic and does not need to be: it is that the signing screen must show the creator, which makes it one of the things a member is agreeing to. See Phase 4.

The proposer's identity is still recorded independently, on the FrostSigningSession the proposal opened. The two can disagree, and when they do the session is the fact about who acted while the tag is the group's claim about whose work it is. Those are different questions and both are worth keeping.

ownerId survives as ContributorTags 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 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 below, both of which the creator now simply avoids.

ROLE_OWNER has to reach KNOWN_ROLES, in the same commit

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, 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.

fun addUnique(tag: Array<String>): TagArrayBuilder<T> {
    if (tag.isEmpty() || tag[0].isEmpty()) return this
    tagList[tag[0]] = mutableListOf(tag)   // replaces the whole list
    return this
}

Emitting three owners with addUnique leaves one. Use addUniqueValueIfNew, which appends and dedupes on tag[1] — the pubkey — so a key listed twice by a form appears once. For a set of owners that is the correct semantics; addUniqueValueIfNew was the wrong call only back when creator and owner shared the tag name and deduping by pubkey would have collapsed a person who was both.

Emit them sorted. The id is a hash over the tag array as emitted, so the order owners come out in is part of what the group signs. A multi-select hands them over in selection order, which is nothing in particular. DkgSession.participantPublicKeys already sorts for a version of this reason — "so two devices that assembled the set in different orders store the same string" — and the same line makes a project canonical, keeps the owner list on the detail screen stable, and costs a .sorted().

Selecting by role, not position, is no longer a trap: with the creator gone from the p tag, every ContributorTag on a project event is an owner. Filter on the role anyway — it.role == ROLE_OWNER — because that assumption is one future role away from being false, and the failure would be an owner list quietly containing somebody else.

What the p tag costs the owners, and why it is acceptable here

A p tag is not an inert field in nostr. NIP-01 indexes single-letter tag names and makes them queryable as #p, so it means "this pubkey is involved": it is what relays index, what Nip17Filters subscribes on, and what a notification system reads as a mention.

Inside a Marmot group none of that applies — these events are never on a relay as themselves. But BroadcastGroupSignedEventScreen takes an event the group signed and publishes it to public relays, and a broadcast project would put its owners' keys into those relays' addressed-pubkey index, queryable, with a client free to notify an owner about a project they were not told about.

For owners that is tolerable and arguably right: an owner is involved, which is what the tag means, and a project the group chose to publish is one it chose to publish under their names. It is still worth the broadcast screen saying whose keys are in what is about to leave, and that belongs on the list of things that screen should say rather than in this feature.

The creator escapes this entirely, which is a second small reason the split is the right one: creator is multi-character, so it is not indexed, not queryable and not an address, and a broadcast project carries the creator as data rather than as a recipient.

One thing not to copy

Six existing tags carry a stray Tag suffix in the wire value — chapterIdTag, chunkIdTag, dialectIdTag, translationIdTag, translationChapterIdTag, translationChunkIdTag — while artifactId, artifactVersionId and translationArtifactVersionId do not. It is a copy-paste slip and it is unfixable: those strings are inside event ids the group has already signed, so renaming them would invalidate every signature over them. The new tag is plain creator, and the slip is worth a comment where somebody would otherwise tidy it.

Three kinds, and where they can go

The nip30303 band is full, and worse than full.

30300 Artifact                     30310 TranslationContributorList   / DKG proposal
30301 ArtifactVersion              30311 Translation                  / DKG host key
30302 Chapter                      30312 Submission                   / DKG round 1
30303 Chunk                        30313                                DKG coordinator round 1
30304 Dialect                      30314                                DKG round 2
30305 TranslationAVContributorList 30315                                DKG certificate
30306 TranslationArtifactVersion   30316                                DKG failure
30307 TranslationChapterContribLst 30320-30325                          FROST signing
30308 TranslationChapter           30326                                GroupKeyState
30309 TranslationChunk             30327-30328                          Chronicle
                                   30329                                SubgroupBirthCertificate

Three kinds are double-booked. SubmissionEvent.KIND is 30312 and DkgRitualEvents.ROUND_1 is 30312; 30310 and 30311 collide the same way. It has been survivable because the two families travelled in different envelopes — a ceremony's messages were NIP-17 gift wraps between participants and a submission was a Marmot inner event — and since subgroups moved a child's ChillDKG into the parent's Marmot room that separation is thinner than it was. None of the three colliding nip30303 kinds has a producer (TranslationEvent has an inbound arm and nothing that builds one; the contributor lists have neither), which is the only reason this is a latent bug rather than a live one.

So: do not extend the 303xx nip30303 band, and do not fill the 30317–30319 gap, which is the ceremony family's growth room. Projects take the three slots after the highest kind in use:

30330  ProjectEvent
30331  ProjectArtifactVersionEvent
30332  ProjectTranslationArtifactVersionEvent

Worth doing in passing, in Phase 0, because it costs a comment and this document is the only place anyone will look: a note on DkgRitualEvents and on SubmissionEvent saying the two bands overlap and which side has no producer. A collision nobody has written down is one somebody resolves by moving the wrong family.

One event per membership, not one list per project

Two shapes are available for "the versions in this project".

A list event per project, replaceable, carrying every member as a tag. One event to sign when membership changes, and removal is free — the new list simply omits it.

An event per membership, which is what the server's schema describes and what every other entity in this family is. Append-only; removal needs something extra.

Take the second, for three reasons. The first is that every nip30303 entity here is its own event and its own row, and the only list-shaped kinds in the family — the three contributor lists, 30305/30307/30310 — are precisely the ones that were never made to work. applyInnerEvent has no arm that writes a row for any of them, MantraTranslationArtifactVersionContributor has a companion full of TODOs 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.

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 ContributorTags 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:

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. 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:

class CreatorTag(val pubKey: HexKey) {
    companion object {
        const val TAG_NAME = "creator"

        fun parse(tag: Array<String>): CreatorTag? {
            ensure(tag.has(1)) { return null }
            ensure(tag[0] == TAG_NAME) { return null }
            ensure(tag[1].length == 64) { return null }
            return CreatorTag(tag[1])
        }

        fun assemble(pubKey: HexKey): Array<String> = arrayOf(TAG_NAME, pubKey)
    }
}

The length check is the one guard that matters: without it a malformed value parses, lands on the row, and names nobody. ContributorTag has the same check for the same reason, which is what makes an owner's key safe to take from a form.

ProjectIdTag is an EventReference tag, copied from ArtifactIdTag exactly — the ensure(tag.has(1)), the tag[1].length == 64, the optional relay hint at index 2 and pubkey at 3. ArtifactVersionIdTag and TranslationArtifactVersionIdTag already exist and are what the two membership events point with, so no other tag is new. NameTag already exists.

ProjectEvent:

const val KIND = 30330
const val ALT_DESCRIPTION = "Project"

fun name() = tags.firstNotNullOfOrNull(NameTag::parse)?.name

fun creator() = tags.firstNotNullOfOrNull(CreatorTag::parse)?.pubKey

fun owners() = tags.mapNotNull(ContributorTag::parse)
    .filter { it.role == ContributorTag.ROLE_OWNER }
    .map { it.pubKey }

fun build(
    name: String,
    description: String,
    creatorPublicKey: HexKey,
    ownerPublicKeys: Set<HexKey>,
    createdAt: Long = TimeUtils.now(),
    initializer: TagArrayBuilder<ProjectEvent>.() -> Unit = {},
) = eventTemplate(KIND, description, createdAt) {
    require(ownerPublicKeys.isNotEmpty()) { "A project has at least one owner" }

    alt(ALT_DESCRIPTION)
    addUnique(NameTag.assemble(name))
    addUnique(CreatorTag.assemble(creatorPublicKey))
    // Sorted, because the id is a hash over the tags as emitted and a form hands
    // its selection over in no particular order. addUniqueValueIfNew, not
    // addUnique, which would keep one p-tag of the several.
    ownerPublicKeys.sorted().forEach {
        addUniqueValueIfNew(ContributorTag.assemble(it, ContributorTag.ROLE_OWNER))
    }
    initializer()
}

Content is the description. Name and creator are single-value tags, so addUnique as every sibling does; the owners are a set, so addUniqueValueIfNew in sorted order. owners() filters on the role rather than taking every p tag, which is true today and one future role away from not being.

A Set rather than a List, and non-empty by contract: the caller always has an answer, since the room's own key is the default, and an empty set would make "owned by us" and "owner unstated" the same event.

Tag order is fixed and starts with alt, because the id is a hash over the tags and anything that rebuilds this event has to emit them in the same order or produce an event the signature does not cover. For the owners that includes the order among themselves, which the sort fixes.

The two membership events carry two id tags and an empty content. build takes both ids:

fun build(
    projectId: HexKey,
    artifactVersionId: HexKey,
    createdAt: Long = TimeUtils.now(),
    initializer: TagArrayBuilder<ProjectArtifactVersionEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
    alt(ALT_DESCRIPTION)
    addUnique(ProjectIdTag.assemble(projectId))
    addUnique(ArtifactVersionIdTag.assemble(artifactVersionId))
    initializer()
}

Note TagArrayBuilder<ProjectArtifactVersionEvent> — the real type. Three events in this package take TagArrayBuilder<ArtifactEvent> by copy-paste, which is a phantom generic that happens to compile; do not add a fourth.

No creator tag on a membership, though the server's schema has creatorId on both join models. Two reasons. A membership is an act rather than a work — "this version is part of this project" is a fact about the project, and who moved it there is a fact about process, which the FrostSigningSession already records. And a membership proposed in the founding batch has the same creator as the project it arrives with, so the tag would be redundant in the common case and ambiguous in the other, where it would be the first place in the app two different people are named as the creator of one decision.

If it turns out to be wanted, the cost is a nullable column and one add — the tag parses on an event that lacks it, and a row written without it reads back null. Nothing in Phases 2 through 7 forecloses it.

Also in this phase, and the reason it is a phase of its own: a membershipsOf companion on each membership event, the counterpart to ArtifactVersionEvent.initialVersionOf. It takes the signed project event and the list of version ids the form collected, and returns the templates — borrowing the project's own createdAt, so a project and its first contents read as one act rather than four events that share a session.

Tests (commonTest):

  • a RumorIdAgreementTest case per kind, in the shape the five existing ones have — build the template, hash it the way MantraDao.rumorOf does, and assert the row's id agrees;
  • a ContributorTag case asserting ROLE_OWNER survives parse. This is the test that catches the silent drop, it is three lines, and without it the only symptom is projects with no owners;
  • a CreatorTag case: a well-formed value parses, a value that is not 64 characters does not;
  • a ProjectEvent case with three owners, asserting all three come back and in sorted order — the failure mode of addUnique is one owner, which looks like a form bug rather than a builder bug;
  • a ProjectEvent case where the creator is also an owner, asserting creator() and owners() both name them. The two live on different tags now, so this is a regression test against anyone folding them back together;
  • a ProjectEvent case asserting build rejects an empty owner set.

Phase 2 — three rows, and schema 20 → 21

New, under press.mantra.compose.database.model:

MantraProject.kt
MantraProjectArtifactVersion.kt
MantraProjectTranslationArtifactVersion.kt

MantraProject is MantraArtifact's shape with fewer fields:

@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.

/** The project's owners, or an empty set on a row that somehow has none. */
fun ownerPublicKeySet(): Set<HexKey> = ownerPublicKeys
    .split(",")
    .mapNotNull { key -> key.trim().takeIf { it.isNotEmpty() } }
    .toSet()

A join table would buy querying by owner, which no screen does — Phase 6 says why — and cost a table, a DAO, a cascade and three more lines of apply. Revisit it when something needs "every project this key owns" across rooms, which is Phase 9's territory anyway.

The membership rows add the two foreign keys and nothing else. Both get an index on projectId, because every read of them is "this project's contents" — the same reason v16 added one on ChatMessage.chatRoomId.

No toProjectEvent(). The eight toXEvent() methods exist for one purpose: rebuilding events for a chronicle in rooms whose work predates GroupSignedEvent (schema v13). They are deprecated, there is a removal checklist in docs/member-chronicle.md, and each one drags a case of ChronicleRoundTripTest behind it. Projects post-date v13 by seven schema versions, so every project this app will ever hold is on file as a signed event and signedEventsOf finds it. Adding three more rebuilds would be adding to a mechanism being retired, to serve rows that cannot exist.

Schema. Three tables, nothing existing changes shape, so this is an AutoMigration(from = 20, to = 21) entry with a comment in the style of the other nineteen, plus three entities in @Database, three abstract funs on MantraDatabase, and three DAOs.

The DAOs are MantraDialectDao's shape — an interface, @Upsert, and the queries each screen needs:

@Query("SELECT * FROM MantraProject WHERE chatRoomId = :chatRoomId ORDER BY name")
suspend fun getProjectsByChatRoomId(chatRoomId: String): List<MantraProject>

@Query("SELECT * FROM MantraProject WHERE id = :id")
suspend fun getProjectById(id: String): MantraProject?

and, on the membership DAOs, getByProjectId and the inverse getProjectsByArtifactVersionId — the inverse is what lets an artifact's own screen say which projects it is part of, in Phase 7.

Tests (jvmTest): a MantraProjectDaoJvmTest in the shape of DkgSessionDaoJvmTest — real in-memory Room over BundledSQLiteDriver — covering upsert, the by-room query, and that a membership naming an absent version is refused by the FK. That last one is the Phase 1 scope decision made testable.

Phase 3 — applying a signed project

Three arms in ChatMessage.applyInnerEvent, which is where every nip30303 kind lands whether it arrived over the wire or was signed locally — FrostSigningManager reuses this dispatch rather than repeating it.

ProjectEvent.KIND writes the row and a chat line:

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".

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:

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:

private val APPLY_ORDER: List<Kind> = listOf(
    DialectEvent.KIND,
    ArtifactEvent.KIND,
    ArtifactVersionEvent.KIND,
    ChapterEvent.KIND,
    ChunkEvent.KIND,
    TranslationArtifactVersionEvent.KIND,
    TranslationChapterEvent.KIND,
    TranslationChunkEvent.KIND,
    ProjectEvent.KIND,                              // new
    ProjectArtifactVersionEvent.KIND,               // new
    ProjectTranslationArtifactVersionEvent.KIND,    // new
)

Order is the whole content of this phase. APPLY_ORDER is dependency order and a page applies front to back, so a membership must sort after both the project and the version it names. Appending gets that right: the memberships are last, and everything they can name is already in the list. CHRONICLABLE_KINDS, applyRank, inApplyOrder and isChroniclable are all derived from this one list and need no change.

Projects are chroniclable where the contributor lists are not, and for the opposite of their reason: these have an apply arm that writes a row, and they are signed rather than submitted, so a receiver can check them. A late member who gets a room's chronicle and not its projects gets the whole library and no account of what any of it is for.

rebuiltEventsOf — the deprecated walk — is not touched, per Phase 2.

Tests (commonTest / jvmTest): a ChronicleEventTest case asserting the three ranks, specifically that each membership outranks the project and the version kind it names; a ChronicleAssemblyJvmTest case asserting a room with a project chronicles it.

Phase 6 — the repository

MantraRepository gains, with DatabaseMantraRepository implementing and NO_OP returning empty:

suspend fun getProjects(chatRoomId: String): List<MantraProject>
suspend fun getProject(id: String): MantraProject?
suspend fun getProjectArtifactVersions(projectId: String): List<MantraArtifactVersion>
suspend fun getProjectTranslationArtifactVersions(projectId: String): List<MantraTranslationArtifactVersion>
suspend fun getProjectsForArtifactVersion(artifactVersionId: String): List<MantraProject>

No query by creator or owner. Both are columns on the row and a screen that wants them has the row; a getProjectsOwnedBy would be a query with one caller and an index to go with it, and the room's project list is short. Add it when a screen asks, not before.

The two contents queries return the versions, not the join rows: no screen wants a join row, and resolving it in the repository is one query there instead of an N+1 in a view model. Room does this with a join; both are indexed by Phase 2.

Nothing goes in MantraDao. Its addDialect / addArtifactVersion / rumorOf / submitToGroup path is the old submit flow, reached now only from MantraDaoJvmTest, and every entity a member creates is proposed for signing instead. An addProject there would be a second way to create a project that bypasses the quorum, which is the thing this feature is for.

Phase 7 — the screens

Five files per screen, in the shape every screen in this app has: a @Serializable data class route, a sealed UIState (Loading / Error / Loaded), a view model with a factory, the composable, and a composable<Route> entry in MantraNavHost.

ChatRoomDetailScreen gets a projects section, between the library and the dialects, copying the artifacts section exactly: a labelMedium heading, an empty line when there are none, a Card + ListItem per project navigating to ProjectDetailRoute, and a TextButton to AddProjectRoute. ChatRoomDetailUIState.Loaded gains projects: List<MantraProject> = emptyList() and the view model's initiate loads them.

AddProjectScreen / AddProjectViewModel / AddProjectRoute. Name and description fields, a multi-select of the room's artifact versions and a multi-select of its translations, an owner, and one primary action. The view model is AddArtifactViewModel's shape, including the parts that are not obvious:

  • the creator is activeUserPublicKey, not a field. It is who is filling the form in, the view model already has it, and a picker would only let somebody credit the wrong person;
  • the owners are a list, pre-filled with the room's own id and labelled as this group, with an add control and a remove on each. Most projects never touch it, and that is the point of pre-filling rather than hiding: a group that is making something for somebody else needs the question asked, and a group that is not needs to see the answer it is giving. Each entry takes a pubkey or an npub; validate to 64 hex on entry, since ContributorTag.parse drops a tag whose value is not that length and the owner would vanish between the form and the signature. The list cannot be emptied — build requires one and the last remove is disabled rather than allowed and then refused;
  • isActionPending, guarding the double tap;
  • canSign on UIState.Loaded from frostSigningRepository.canSign, with the FAB borrowing ButtonDefaults.buttonColors().disabled* and Modifier.semantics { disabled() } when it cannot;
  • proposeSigningBatch(lead = projectTemplate, dependents = …), where dependents reads the signed project's id and returns both membership lists. The project's id is a hash over the group's key at the room's path, which this screen does not know and should not;
  • on success, navigate to FrostSigningRoute(session.id) with popUpTo<AddProjectRoute> { inclusive = true }.

A project with no contents is allowed — unlike an artifact with no version, which is inert because nothing can attach to it, an empty project is a name waiting for work and Phase 7's second flow is how work reaches it.

ProjectDetailScreen / ProjectDetailViewModel / ProjectDetailRoute. Name, description, creator, owner, the source versions, the translations, and two add buttons. Lists load once in initiate(); to reflect a just-signed change, navigate to a fresh ProjectDetailRoute with popUpTo<ThatRoute> { inclusive = true }. DetailRow is the shared label/value widget.

Creator is a DetailRow and the owners are a short list, both showing the profile rather than the hex — resolve through ProfileDao and fall back to a truncated npub, which is what the rest of the app does with a key it has no profile for. An owner entry that is the room's own key reads "this group" rather than printing the room id back at a member standing inside it, and sorts first, so the common single-owner case is one unremarkable line.

AddToProjectScreen — pick a version, propose one membership. This is the flow that needs Phase 4's resolver, because here the membership is the entire proposal and a truncated hash is not a thing to sign.

Each screen obeys CLAUDE.md, and the audit is not optional — docs/scripts/m3-audit.sh --check runs inside ./gradlew check and fails on a budget exceeded:

  • spacing from MaterialTheme.spacing, never a .dp literal in a spacing position (budget 0);
  • colour from a role, never a Color(0x…) and effectively never .copy(alpha=) (budget 0 outside ui/theme/);
  • every string in composeResources/values/strings.xml, sentence case — "Add project", "No projects in this group", not "Add Project" (budget 0 title-case strings);
  • NavigateBackButton(onNavigateBack) in the app bar of all four, since all four are pushed and the desktop target has neither a system back nor an edge swipe;
  • Modifier.padding(innerPadding).readableContent() on each content root;
  • all four states said, with ErrorState(onRetry = …) and EmptyState's message given explicitly;
  • ScreenStateTransition(viewModel.uiState) { … } wrapping the state when, and only where the when is the composable's whole body;
  • icons with a decided contentDescription — a real one, or Decorative where the label is beside it, never a bare null.

Around twenty new strings — the six beyond the screens themselves are the owners field's label, its "this group" value, its add and remove actions, its validation error, and the creator row's label. Check grep -c "<string" before and after; the catalogue is at 460.

Phase 8 — removing something from a project

An append-only membership log has no way to say "not any more", and this is the cost of the Phase 2 shape, paid here rather than designed around.

Two options, and they should be weighed with the schema in front of you rather than now. A revocation event (30333) naming the membership it ends, which is how a signed log normally retracts and which keeps the room's record of both decisions — at the price of a fourth kind, a fourth arm, a fourth chronicle rank, and every read of a project's contents becoming a left-join against revocations. Or supersession, the mechanism TranslationChunkEvent already uses: a new event replaces an old one and the apply arm drops what it replaced, with the per-kind replace/dedupe rule living in applyInnerEvent. That is cheaper and fits a correction better than a removal; there is no "nothing" for a membership to be superseded by.

Lean toward revocation, and note that the chronicle has a stake in whichever is chosen: TranslationChunkEvent's supersession means a chronicle carries the group's current answer rather than its drafts, and a revocation means a chronicle has to carry the revocation too or a late member sees a membership the group ended.

Phase 9 — projects across rooms

Described under the scope fork. One migration dropping two foreign keys, a room id beside each referenced event id, an apply arm that stores what it cannot resolve, and a UI that can show an unresolved member without looking broken. Follow parentChatRoomId's precedent — a plain column, never a foreign key, because a dangling value is the normal state.

Verifying

ANDROID_HOME=/home/sigidli/Android/Sdk ./gradlew :composeApp:compileDebugKotlinAndroid
./gradlew :composeApp:jvmTest :composeApp:m3Audit

Room-backed tests go in composeApp/src/jvmTest, not commonTest — the android unit-test variant's PlatformDatabaseBuilder needs a real Context and there is no Robolectric. In a fresh worktree, bootstrap the four nested submodules first.

Rollout

Phases 0–6 are invisible: new kinds nothing proposes, new tables nothing writes, new arms nothing reaches. They can land in any order after 0, and each compiles and tests on its own.

Phase 7 is what turns the feature on, and it is one-way in the sense that matters: once a group has signed a project, every member's device must understand kind 30330 to make sense of its room. A member on an older build sees three unrecognised kinds — no row, no chat line, and GroupSignedEvent keeps the event, so the row appears when they upgrade. That is the same degradation every kind in this family has had, and it is why the inbound half ships before the screens that produce anything.

Phase 8 should land close behind 7. A group that can add and cannot remove will want to remove within a week.

What this does not do

  • Funding. No campaign, no pledge, no satoshis, and no column held open for one. It is a feature with its own kinds and it needs projects to exist first.
  • Proof of authorship. The creator tag is the group attesting to a claim the proposer wrote; the creator does not sign anything. The FrostSigningSession records who actually acted, and the two can disagree — which is a fact worth having, not a defect to reconcile.
  • An owner that is not a key. The server's owner → ownerEntity → entity chain can name an organization with members, an email and a type. A set of pubkeys carries none of that. If an owner needs to be a described thing rather than an addressable one, that is an entity feature and it is not this.
  • Changing the owners. They are tags on the project event, so the owner set is fixed at the quorum that signed it. Editing it means a new project event superseding the old, which is the same question Phase 8 asks about memberships and should be answered with it rather than separately.
  • Creator or owner on a membership. One tag and one column away, deliberately left out — see Phase 1.
  • Removal until Phase 8, cross-room until Phase 9.
  • Project-level approval or progress. The server has artifactApproval, artifactVersionApproval and translationArtifactVersionApproval, none of which exists here either. A project is a grouping; what a group says about the grouping is later work.