feat(projects): say what a project is to the member being asked to sign it

Phase 4 of docs/project-feature.md. Three branches in `ProposedEvent.summarize`,
or members would be asked to sign "Event of kind 30330".

The project's branch shows the creator, and that is not decoration. The creator
tag is the group attesting that X started this; the creator signs nothing, the
room does, so a member could propose a project crediting somebody else and a
quorum that does not read would sign it. Nothing checks this and nothing can --
the only mitigation is that the claim is in front of the person agreeing to it.

`summarize` gains two optional parameters. `chatRoomId` is read by one branch
and only to leave something out: a project owned by the room it is in is the
ordinary case, and saying "owned by this group" every time is how a member
learns to stop reading the line that matters when somebody else is on it. All
four call sites already had the room, so this is threaded rather than plumbed --
including through `WhatIsBeingSigned`, which took the events and not the state.

`resolve` is the seam for turning keys and ids into names, synchronous because
two of the four call sites are composables and cannot await a database. It
defaults to resolving nothing, which is honest rather than finished: a
membership event carries two 64-character ids, so without it the summary is a
truncated hash. That is tolerable in the batch that founds a project -- the
project's own line says what is happening and the memberships read as its
continuation, and the project id is unresolvable there anyway since nothing has
applied it yet. It is not tolerable for a membership proposed on its own, which
is Phase 7's second screen, and that is where the resolver gets filled in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso NgakoandClaude Opus 5 committed 2026-10-02 19:16:21 +02:00
1 parent 6e97735e47
commit 9430396e5a
6 files changed
+241 -9

No files matched your search

@@ -1,6 +1,7 @@
package press.mantra.compose.text
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import press.mantra.compose.managers.SharedKeyDerivation
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent
@@ -8,6 +9,9 @@ import press.mantra.compose.nostr.nip30303.ArtifactEvent
import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.ChapterEvent
import press.mantra.compose.nostr.nip30303.DialectEvent
import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.ProjectEvent
import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.TranslationChapterEvent
import press.mantra.compose.nostr.nip30303.TranslationChunkEvent
@@ -31,7 +35,34 @@ object ProposedEvent {
*/
data class Summary(val label: String, val detail: String)
fun summarize(event: Event): Summary = when (event.kind) {
/**
* [chatRoomId] is the room the proposal is in, where one is known.
*
* Only one branch reads it, and only to leave something out: a project owned
* by the room it is in is the ordinary case, and saying "owned by this group"
* on every project trains a member not to read the line that matters when
* somebody else is on it. Null simply says everything.
*
* [resolve] turns a public key or an event id into something a person can
* read, or null when it cannot. It is synchronous on purpose -- this is
* called from composables as well as view models -- so a caller with a
* database behind it resolves what it needs first and passes a lookup into
* the map.
*
* The default resolves nothing, which is honest rather than ideal. A
* membership event carries two 64-character ids and nothing else, so without
* a resolver it can only be described by a truncated hash. That is tolerable
* inside the batch that founds a project, where the project's own line names
* what is happening and the memberships read as its continuation -- and in
* that batch the project id is unresolvable anyway, since nothing has
* applied it yet. It is not tolerable for a membership proposed on its own,
* which is the whole of what a member is being asked to agree to.
*/
fun summarize(
event: Event,
chatRoomId: String? = null,
resolve: (HexKey) -> String? = { null },
): Summary = when (event.kind) {
DialectEvent.KIND -> Summary(
label = "New dialect",
detail = DialectEvent(
@@ -125,6 +156,56 @@ object ProposedEvent {
}
)
ProjectEvent.KIND -> Summary(
label = "New project",
detail = ProjectEvent(
event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig
).let { project ->
listOfNotNull(
project.name(),
event.content.takeIf { it.isNotBlank() },
// Shown because signing this is what makes it the group's
// statement that X started it. A member who cannot see the
// claim cannot be said to have agreed to it -- and nothing
// else checks it, since the creator signs nothing here.
project.creator()?.let { "started by ${describe(it, resolve)}" },
// Only the owners who are not this room. See [chatRoomId].
project.owners()
.filter { it != chatRoomId }
.takeIf { it.isNotEmpty() }
?.joinToString(", ") { describe(it, resolve) }
?.let { "owned by $it" },
).joinToString(" · ")
}
)
ProjectArtifactVersionEvent.KIND -> Summary(
label = "A source version joins the project",
detail = ProjectArtifactVersionEvent(
event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig
).let { membership ->
// The version first: in a founding batch the project is being
// signed two lines up and its id resolves to nothing, because
// nothing has applied it yet.
listOfNotNull(
membership.artifactVersionId()?.let { describe(it, resolve) },
membership.projectId()?.let { resolve(it) }?.let { "to $it" },
).joinToString(" · ").ifBlank { "Unknown version" }
}
)
ProjectTranslationArtifactVersionEvent.KIND -> Summary(
label = "A translation joins the project",
detail = ProjectTranslationArtifactVersionEvent(
event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig
).let { membership ->
listOfNotNull(
membership.translationArtifactVersionId()?.let { describe(it, resolve) },
membership.projectId()?.let { resolve(it) }?.let { "to $it" },
).joinToString(" · ").ifBlank { "Unknown translation" }
}
)
// The one thing a group signs that is about the group rather than about
// its work, and the only one a member sees before the room has done
// anything. Shown as the path and the ceremony rather than as the key:
@@ -157,4 +238,14 @@ object ProposedEvent {
else -> Summary(label = "Event of kind ${event.kind}", detail = event.content)
}
/**
* [id] as a name if [resolve] has one, and as a short hash otherwise.
*
* Twelve characters because that is what the rest of the signing screens
* truncate to. A short hash is a poor thing to put in front of somebody
* deciding whether to sign, and saying so is the point: see [summarize].
*/
private fun describe(id: HexKey, resolve: (HexKey) -> String?): String =
resolve(id) ?: id.take(12)
}
@@ -188,7 +188,10 @@ fun BroadcastGroupSignedEventScreen(
// what goes on the wire, because the point of copying it is to
// hand somebody something they can verify.
val json = signedEvent.toEvent().toJson()
val summary = ProposedEvent.summarize(signedEvent.toEvent())
val summary = ProposedEvent.summarize(
event = signedEvent.toEvent(),
chatRoomId = signedEvent.chatRoomId,
)
val canSend = !isPending
@@ -165,7 +165,11 @@ fun FrostSigningScreen(
.padding(MaterialTheme.spacing.space250),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space200)
) {
WhatIsBeingSigned(proposed, state.items.size)
WhatIsBeingSigned(
events = proposed,
expected = state.items.size,
chatRoomId = state.localChatRoom.chatRoom.id,
)
HorizontalDivider()
@@ -335,7 +339,7 @@ private fun Loading(padding: androidx.compose.foundation.layout.PaddingValues) {
* sign something they cannot check.
*/
@Composable
private fun WhatIsBeingSigned(events: List<Event>, expected: Int) {
private fun WhatIsBeingSigned(events: List<Event>, expected: Int, chatRoomId: String) {
if (expected == 0 || events.size != expected) {
Text(
text = if (expected <= 1) {
@@ -361,7 +365,7 @@ private fun WhatIsBeingSigned(events: List<Event>, expected: Int) {
events.forEachIndexed { index, event ->
if (index > 0) HorizontalDivider()
OneThingBeingSigned(event)
OneThingBeingSigned(event, chatRoomId)
}
Text(
@@ -376,8 +380,9 @@ private fun WhatIsBeingSigned(events: List<Event>, expected: Int) {
/** One event of the batch, described as the thing it is. */
@Composable
private fun OneThingBeingSigned(event: Event) {
val summary = ProposedEvent.summarize(event)
private fun OneThingBeingSigned(event: Event, chatRoomId: String) {
// The room, so a project owned by this group does not say so on every line.
val summary = ProposedEvent.summarize(event, chatRoomId = chatRoomId)
Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space50)) {
Text(text = summary.label, style = MaterialTheme.typography.labelMedium)
@@ -228,7 +228,12 @@ class ChatMessageListViewModel(
// something, and the notice should say what.
lead = local.items.firstNotNullOfOrNull { item ->
Event.fromJsonOrNull(item.unsignedEventJson)
?.let { ProposedEvent.summarize(it) }
?.let {
ProposedEvent.summarize(
event = it,
chatRoomId = localChatRoom.chatRoom.id,
)
}
},
eventCount = local.items.size
)
@@ -86,7 +86,7 @@ class ProposalListViewModel(
else -> local.proposer?.humanReadableNameOrPubkey()
?: local.session.coordinatorPublicKey.shortened()
},
signs = events.map { ProposedEvent.summarize(it) },
signs = events.map { ProposedEvent.summarize(it, chatRoomId = chatRoomId) },
unreadable = items.size - events.size,
awaitsYou = FrostSigningManager.isAwaitingApproval(local.session, items)
)
@@ -1,8 +1,12 @@
package press.mantra.compose.text
import com.vitorpamplona.quartz.nip01Core.core.Event
import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.ProjectEvent
import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* How an event a group signs is said to the member signing it.
@@ -23,6 +27,130 @@ class ProposedEventTest {
assertEquals("An article", summary.detail)
}
// --- projects ---
//
// A project says who started it and who owns it, and both are claims the
// group is being asked to put its signature to rather than facts anybody
// checks. The creator signs nothing; the owners sign nothing. So the only
// thing standing between "the group attested that X started this" and "the
// proposer typed X" is whether the member signing could see the claim. That
// is what these pin.
private val roomId = "r".repeat(64)
private val creator = "c".repeat(64)
private val outsider = "d".repeat(64)
private fun project(owners: Set<String>) = ProjectEvent.build(
name = "Gospel of John",
description = "The four chapters.",
creatorPublicKey = creator,
ownerPublicKeys = owners,
createdAt = 1_700_000_000,
).let {
Event("a".repeat(64), roomId, it.createdAt, it.kind, it.tags, it.content, "")
}
@Test
fun `a project names itself and whoever started it`() {
val summary = ProposedEvent.summarize(project(setOf(roomId)), chatRoomId = roomId)
assertEquals("New project", summary.label)
assertTrue(summary.detail.contains("Gospel of John"))
assertTrue(summary.detail.contains("The four chapters."))
assertTrue(summary.detail.contains("started by ${creator.take(12)}"))
}
@Test
fun `a project owned by the room it is in says nothing about owners`() {
// The ordinary case. Saying "owned by this group" every time is how a
// member learns to stop reading the line.
val summary = ProposedEvent.summarize(project(setOf(roomId)), chatRoomId = roomId)
assertTrue(!summary.detail.contains("owned by"), summary.detail)
}
@Test
fun `a project owned by somebody else says who, and not the room`() {
val summary = ProposedEvent.summarize(
project(setOf(roomId, outsider)),
chatRoomId = roomId
)
assertTrue(summary.detail.contains("owned by"), summary.detail)
assertTrue(summary.detail.contains(outsider.take(12)), summary.detail)
assertTrue(!summary.detail.contains(roomId.take(12)), summary.detail)
}
@Test
fun `with no room to compare against, every owner is named`() {
val summary = ProposedEvent.summarize(project(setOf(roomId, outsider)))
assertTrue(summary.detail.contains(roomId.take(12)), summary.detail)
assertTrue(summary.detail.contains(outsider.take(12)), summary.detail)
}
@Test
fun `a resolver turns the keys into names`() {
val names = mapOf(creator to "Kgothatso", outsider to "Exonumia")
val summary = ProposedEvent.summarize(
project(setOf(roomId, outsider)),
chatRoomId = roomId,
resolve = names::get
)
assertTrue(summary.detail.contains("started by Kgothatso"), summary.detail)
assertTrue(summary.detail.contains("owned by Exonumia"), summary.detail)
}
@Test
fun `a membership is a hash without a resolver and a name with one`() {
val versionId = "f".repeat(64)
val projectId = "e".repeat(64)
val source = ProjectArtifactVersionEvent.build(
projectId = projectId,
artifactVersionId = versionId,
createdAt = 1_700_000_000,
).let {
Event("a".repeat(64), roomId, it.createdAt, it.kind, it.tags, it.content, "")
}
// What a member sees in a founding batch, where the project is being
// signed on the line above and resolves to nothing because nothing has
// applied it yet.
val bare = ProposedEvent.summarize(source, chatRoomId = roomId)
assertEquals("A source version joins the project", bare.label)
assertEquals(versionId.take(12), bare.detail)
// And what it has to look like before a membership is proposed on its
// own, which is the whole of what is being agreed to there.
val named = ProposedEvent.summarize(
source,
chatRoomId = roomId,
resolve = mapOf(versionId to "To Kill a Mockingbird 1.0", projectId to "Gospel of John")::get
)
assertEquals("To Kill a Mockingbird 1.0 · to Gospel of John", named.detail)
}
@Test
fun `a translation joining a project is named as a translation`() {
val translationId = "f".repeat(64)
val membership = ProjectTranslationArtifactVersionEvent.build(
projectId = "e".repeat(64),
translationArtifactVersionId = translationId,
createdAt = 1_700_000_000,
).let {
Event("a".repeat(64), roomId, it.createdAt, it.kind, it.tags, it.content, "")
}
val summary = ProposedEvent.summarize(membership, chatRoomId = roomId)
assertEquals("A translation joins the project", summary.label)
assertEquals(translationId.take(12), summary.detail)
}
private fun event(
kind: Int,
tags: Array<Array<String>> = emptyArray(),