Files
mantra-kmp/docs/project-feature.md
Kgothatso Ngako 3cd9001c27 Merge branch 'mantra' into claude/project-feature-plan-b58ab2
Eighteen commits of subgroup adoption and key-ceremony work, and one collision
that was the point of writing Phase 0 down.

**Schema.** `mantra` took 21 for `GroupKeyStateParent` -- a room's parentage as a
row, so it can have two -- as a manual `MIGRATION_20_21` that copies before it
drops. Projects had taken 21 on this branch. Neither had shipped, so projects
renumbered to **22**, an `AutoMigration(from = 21, to = 22)` on top of theirs;
`schemas/21.json` is now theirs and `22.json` is the two together, 58 tables.
docs/project-feature.md records it, including that Phase 0 told us to check for
exactly this and checked the sibling fork rather than the base branch.

Three conflicts. `MantraDatabase.kt` wanted both entity sets and both
migrations, and their v21 contributes only a comment there since the entry is
manual. `ProposedEventTest.kt` and the schema file were additive either way.

Nine files auto-merged, and `ChatMessage.kt` was the one to watch: they reshaped
the `SubgroupBirthCertificateEvent` arm while this branch added three project
arms to the same `when`. Git saw no conflict and the compiler agrees.

**One test fails, and it arrives with the merge rather than from it.**
`NavigationRoutingTest` reports "uncaught exceptions before the test started",
which is leakage, not its own failure: `CeremonyQueueRoutingJvmTest` (new in
b6c3e3dd) builds a `ChatMessageListViewModel` over
`NO_OP_NOSTR_REPOSITORY`, whose `queueNegentropySynchronizeRequest` is a
`TODO()`, and it throws on a background dispatcher. Whichever `runTest` starts
next is blamed, so it is order-dependent -- the same test passes alone and in
smaller selections. This branch touches neither `scheduleSynchronization` nor
`NostrRepository`; its only edit to that view model is the `summarize` call.

1051 jvmTest with that one failure, 482 testDebugUnitTest clean, m3Audit's
budgets all met.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 20:48:17 +02:00

1078 lines
55 KiB
Markdown
Raw Blame History

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