feat: sign an artifact into the library instead of submitting one

Adding an artifact no longer creates one. It opens a signing session over
an ArtifactEvent, and the artifact appears -- on every member's device at
once, authored by the group's shared key rather than by whoever typed it
-- when enough members have signed. The same trade the dialects made: a
submission says "I am putting this in front of the group" and the group's
only recourse afterwards is social, while a signature is the group saying
it and it takes a quorum to say. A library is the group's.

**The first version.** This is the part the dialect had no answer for. An
artifact was creating an initial ArtifactVersion as a second submitted
event, and that cannot survive the change: a chapter attaches to a
version rather than to an artifact, so an artifact without one is inert,
but a version cannot be submitted before the artifact it points at
exists, cannot have its own quorum without costing a second signing
session per form, and cannot be invented locally -- an invented id
differs on every device, so members would silently disagree about which
version a chapter hangs off while every screen showed the same artifact.

So the label rides on the artifact as an `artifactVersion` tag and the
row is derived from the signed artifact's own fields when it is applied.
Same bytes in, same row out, everywhere. It is a rumor, because nobody
signed it; what the group signed is the artifact that declares it.

**What went away.** MantraDao.addArtifact and its way up through the
repository. Nothing called it once the screen proposed instead, and
leaving a path that authors an artifact under a member's key while the UI
insists on a quorum would have double-created the version besides.

**Tests.** Three files, and each was checked against a broken
implementation rather than only against a working one: deriving the
version from the clock, dropping the label from the proposal, authoring
the derived row as its reader, and losing the signature on the way out of
the session are all caught. SignedArtifactTest runs a real 2-of-3 quorum
over an actual proposal, because the claim worth holding -- the row is
the group's, and carries proof of it -- is invisible when it breaks.

Not covered: applyInnerEvent's two upserts, which need a database no test
here stands up, and AddArtifactViewModel, which is plumbing across two
dispatchers over a template the tests already pin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso NgakoandClaude Opus 5 committed 2026-09-06 01:37:58 +02:00
1 parent 02117643c4
commit 786c0602da
15 files changed
+695 -175

No files matched your search

@@ -9,7 +9,6 @@ import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import press.mantra.compose.database.MantraDatabase
import press.mantra.compose.database.model.ChatMessage
import press.mantra.compose.database.model.MantraArtifact
import press.mantra.compose.database.model.MantraArtifactVersion
import press.mantra.compose.database.model.MantraChapter
import press.mantra.compose.database.model.MantraChunk
@@ -19,7 +18,6 @@ import press.mantra.compose.database.model.MantraTranslationChapter
import press.mantra.compose.database.model.MantraTranslationChunk
import press.mantra.compose.database.model.MarmotInnerEvent
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
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.ChunkEvent
@@ -156,62 +154,6 @@ abstract class MantraDao(
}
@Transaction
open suspend fun addArtifact(
localChatRoom: LocalChatRoom,
name: String,
url: String,
versionLabel: String,
dialectId: HexKey,
userPublicKey: HexKey,
visibility: String = DEFAULT_VISIBILITY,
license: String = DEFAULT_LICENSE,
): MantraArtifact? {
// TODO: Verify the active user is an admin of the chat room before allowing this.
val artifactEventTemplate = ArtifactEvent.build(
name = name,
url = url,
visibility = visibility,
license = license,
dialectId = dialectId,
)
val mantraArtifact = MantraArtifact.fromArtifactEventTemplate(
artifactEventTemplate = artifactEventTemplate,
chatRoomId = localChatRoom.chatRoom.id,
userPublicKey = userPublicKey,
) ?: return null
// Persist the artifact locally and submit it to the group.
//
// This covers the "private" visibility case. Permissioned artifacts
// (published as a PublicMessage) and public artifacts (published as a
// plain nostr event) are not implemented yet.
return try {
database.mantraArtifactDao().upsert(mantraArtifact)
submitToGroup(
chatRoomId = mantraArtifact.chatRoomId,
submitterPublicKey = userPublicKey,
payload = rumorOf(artifactEventTemplate, userPublicKey),
text = "Added $name to artifacts",
)
// Every artifact starts with an initial version.
addArtifactVersionInternal(
localChatRoom = localChatRoom,
artifactId = mantraArtifact.id,
versionLabel = versionLabel,
userPublicKey = userPublicKey,
)
mantraArtifact
} catch (error: Throwable) {
logger.e("Failed to add artifact \"$name\" to chat room ${localChatRoom.chatRoom.id}", error)
null
}
}
@Transaction
open suspend fun addArtifactVersion(
localChatRoom: LocalChatRoom,
@@ -602,15 +602,17 @@ data class ChatMessage(
)
}
ArtifactEvent.KIND -> {
val artifactEvent = ArtifactEvent(
id = event.id,
pubKey = event.pubKey,
createdAt = event.createdAt,
tags = event.tags,
content = event.content,
sig = event.sig,
)
MantraArtifact.fromArtifactEvent(
artifactEvent = ArtifactEvent(
id = event.id,
pubKey = event.pubKey,
createdAt = event.createdAt,
tags = event.tags,
content = event.content,
sig = event.sig,
),
artifactEvent = artifactEvent,
chatRoomId = groupId,
)?.let { mantraArtifact ->
database.mantraArtifactDao().upsert(
@@ -619,6 +621,23 @@ data class ChatMessage(
)
)
// An artifact arrives with the version it starts life
// with, derived here rather than sent, so that every
// device holding the artifact holds the same first
// version. Nothing else can hang off an artifact until
// one exists -- a chapter attaches to a version, not to
// an artifact -- so an artifact without one is inert.
MantraArtifactVersion.initialVersionOf(
artifactEvent = artifactEvent,
chatRoomId = groupId,
)?.let { initialVersion ->
database.mantraArtifactVersionDao().upsert(
initialVersion.copy(
marmotGroupEventId = marmotGroupEventId,
)
)
}
ChatMessage(
giftWrapPayloadId = null,
messageType = "artifact",
@@ -10,6 +10,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip31Alts.AltTag
import press.mantra.compose.database.model.traits.OptionalNostrEventEntity
import press.mantra.compose.database.model.traits.TimestampedEntity
import press.mantra.compose.nostr.nip30303.ArtifactEvent
import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.tags.ArtifactIdTag
import press.mantra.compose.nostr.nip30303.tags.ArtifactVersionMetadataTag
@@ -83,6 +84,42 @@ data class MantraArtifactVersion(
}
companion object {
/**
* The version an artifact starts life with, derived from the artifact.
*
* The group signs an artifact; it does not sign this. So the first
* version cannot be an event proposed on its own -- that would cost a
* second quorum for one form -- and it cannot be invented by whichever
* device notices the artifact first, because an invented id differs on
* every device holding the same artifact and none of them would agree
* about which version a chapter hangs off. Deriving it from the signed
* artifact's own fields gives every device the same row from the same
* bytes, which is the only property that matters here.
*
* It is a rumor -- empty signature -- because nobody signed it. What the
* group signed is the artifact that declares it.
*
* Null when the artifact declares no version, which is every artifact
* written before it did.
*/
fun initialVersionOf(
artifactEvent: ArtifactEvent,
chatRoomId: HexKey,
): MantraArtifactVersion? {
val versionLabel = artifactEvent.versionLabel() ?: return null
return fromArtifactVersionEventTemplate(
artifactVersionEventTemplate = ArtifactVersionEvent.build(
content = versionLabel,
createdAt = artifactEvent.createdAt,
) {
addUnique(ArtifactIdTag.assemble(artifactEvent.id))
},
chatRoomId = chatRoomId,
userPublicKey = artifactEvent.pubKey,
)
}
fun fromArtifactVersionEventTemplate(
artifactVersionEventTemplate: EventTemplate<ArtifactVersionEvent>,
chatRoomId: HexKey,
@@ -131,28 +131,6 @@ class DatabaseMantraRepository(
)
}
override suspend fun addArtifact(
localChatRoom: LocalChatRoom,
name: String,
url: String,
versionLabel: String,
dialectId: HexKey,
userPublicKey: HexKey,
visibility: String,
license: String,
): MantraArtifact? {
return database.mantraDao().addArtifact(
localChatRoom = localChatRoom,
name = name,
url = url,
versionLabel = versionLabel,
dialectId = dialectId,
userPublicKey = userPublicKey,
visibility = visibility,
license = license
)
}
override suspend fun addArtifactVersion(
localChatRoom: LocalChatRoom,
artifactId: HexKey,
@@ -9,6 +9,7 @@ import com.vitorpamplona.quartz.nip22Comments.RootScope
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import press.mantra.compose.nostr.nip30303.tags.ArtifactVersionMetadataTag
import press.mantra.compose.nostr.nip30303.tags.DialectIdTag
import press.mantra.compose.nostr.nip30303.tags.LicenseTag
import press.mantra.compose.nostr.nip30303.tags.UrlTag
@@ -31,6 +32,17 @@ class ArtifactEvent(
fun dialectIdReference() = tags.firstNotNullOfOrNull(DialectIdTag::parse)?.ref
fun dialectId() = dialectIdReference()?.eventId
/**
* The label of the version this artifact starts life with.
*
* Carried on the artifact rather than in an event of its own because the
* group signs the artifact and nothing else. A first version proposed
* separately would need either a second quorum for one form or an id each
* device invents for itself, and invented ids differ on every device holding
* the same artifact. Null on artifacts written before this was declared.
*/
fun versionLabel() = tags.firstNotNullOfOrNull(ArtifactVersionMetadataTag::parse)?.versionLabel
companion object {
const val KIND = 30300
const val ALT_DESCRIPTION = "Artifact"
@@ -41,6 +53,7 @@ class ArtifactEvent(
visibility: String,
license: String,
dialectId: String,
versionLabel: String,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<ArtifactEvent>.() -> Unit = {},
) = eventTemplate(KIND, name, createdAt) {
@@ -51,6 +64,7 @@ class ArtifactEvent(
addUnique(
DialectIdTag.assemble(dialectId)
)
addUnique(ArtifactVersionMetadataTag.assemble(versionLabel))
initializer()
}
}
@@ -92,17 +92,6 @@ interface MantraRepository {
userPublicKey: HexKey,
): MantraDialect?
suspend fun addArtifact(
localChatRoom: LocalChatRoom,
name: String,
url: String,
versionLabel: String,
dialectId: HexKey,
userPublicKey: HexKey,
visibility: String = DEFAULT_VISIBILITY,
license: String = DEFAULT_LICENSE,
): MantraArtifact?
suspend fun addArtifactVersion(
localChatRoom: LocalChatRoom,
artifactId: HexKey,
@@ -176,17 +165,6 @@ interface MantraRepository {
userPublicKey: HexKey,
): MantraDialect? = null
override suspend fun addArtifact(
localChatRoom: LocalChatRoom,
name: String,
url: String,
versionLabel: String,
dialectId: HexKey,
userPublicKey: HexKey,
visibility: String,
license: String,
): MantraArtifact? = null
override suspend fun addArtifactVersion(
localChatRoom: LocalChatRoom,
artifactId: HexKey,
@@ -54,6 +54,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import press.mantra.compose.database.model.ChatRoom
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.FrostSigningRepository
import press.mantra.compose.repository.NostrRepository
import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute
import press.mantra.compose.ui.composable.navigation.routes.Route
@@ -62,7 +63,7 @@ import press.mantra.compose.ui.theme.TorchTheme
import press.mantra.compose.ui.view.state.ChatRoomMessagingUIState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import press.mantra.compose.repository.MantraRepository
import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute
import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute
import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute
import press.mantra.compose.ui.view.model.AddArtifactViewModel
import press.mantra.compose.ui.view.state.AddArtifactUIState
@@ -77,6 +78,7 @@ fun AddArtifactScreen(
nostrRepository: NostrRepository,
chatRepository: ChatRepository,
mantraRepository: MantraRepository,
frostSigningRepository: FrostSigningRepository,
onNavigateToRouteAndPopUpInclusive: (Route) -> Unit,
onNavigateToRoute: (Route) -> Unit,
) {
@@ -88,7 +90,8 @@ fun AddArtifactScreen(
nostrRepository = nostrRepository,
chatRepository = chatRepository,
activeUserPublicKey = activeUserPublicKey,
mantraRepository = mantraRepository
mantraRepository = mantraRepository,
frostSigningRepository = frostSigningRepository
)
)
@@ -115,11 +118,12 @@ fun AddArtifactScreen(
// one is picked; dialects are defined from the group detail screen.
var selectedDialectId: String? by remember { mutableStateOf(null) }
// Of the required fields this is the only one the screen cannot ask
// for again: a dialect has to already exist, and nothing here can
// create one. So an unpicked dialect is a dead end rather than
// something to submit and be told about, and the button says so.
val canAddArtifact = selectedDialectId != null
// The two things the form cannot ask for again. A dialect has to
// already exist and nothing here can create one; a shared key has to
// have been ceremonied and this is not where that happens. Either
// missing is a dead end rather than something to propose and be told
// about afterwards, and the button says so.
val canAddArtifact = selectedDialectId != null && addArtifactUIState.canSign
// M3 gives a FAB no `enabled`, so borrow the disabled colours every
// other button in the app uses rather than inventing a shade here.
@@ -168,15 +172,18 @@ fun AddArtifactScreen(
urlField = urlFieldState,
versionLabelField = versionLabelFieldState,
dialectId = selectedDialectId,
onSuccess = { artifactId ->
// Open the newly created artifact, removing this
// add screen from the back stack.
onSuccess = { sessionId ->
// Onto the session rather than to the
// artifact. Nothing has been created yet
// -- the artifact appears when enough
// members sign -- so a detail screen for
// a row that does not exist would read as
// a failure.
onNavigateToRouteAndPopUpInclusive.invoke(
ArtifactDetailRoute(
FrostSigningRoute(
activeUserPublicKey = activeUserPublicKey,
artifactId = artifactId,
chatRoomId = chatRoomId,
relayHint = relayHint
sessionId = sessionId
)
)
},
@@ -191,9 +198,9 @@ fun AddArtifactScreen(
) {
Icon(
Icons.Default.Add,
contentDescription = "Add artifact"
contentDescription = "Propose artifact"
)
Text("Add Artifact")
Text("Propose Artifact")
}
}
)
@@ -209,6 +216,16 @@ fun AddArtifactScreen(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Add Artifact to the group library")
if (!addArtifactUIState.canSign) {
Text(
text = "This group has no shared key, so it cannot sign an " +
"artifact into its library. Run a shared key ceremony first.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
OutlinedTextField(
modifier = Modifier.fillMaxWidth()
.background(BottomAppBarDefaults.containerColor),
@@ -385,6 +402,7 @@ private fun AddArtifactScreenPreview() {
nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY,
frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
onNavigateToRouteAndPopUpInclusive = {},
onNavigateToRoute = {}
)
@@ -304,7 +304,15 @@ private fun WhatIsBeingSigned(event: Event?) {
.joinToString(" · ")
}
ArtifactEvent.KIND -> "New artifact" to event.content
ArtifactEvent.KIND -> "New artifact" to ArtifactEvent(
event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig
).let { artifact ->
// The url is the substance of an artifact -- signing one is putting
// the group's name to what it points at -- so it goes next to the
// name rather than being left for the detail screen afterwards.
listOfNotNull(event.content, artifact.versionLabel(), artifact.url())
.joinToString(" · ")
}
ChapterEvent.KIND -> "New chapter" to ChapterEvent(
event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig
@@ -830,9 +830,12 @@ fun MantraNavHost(
nostrRepository = databaseNostrRepository,
chatRepository = databaseChatRepository,
mantraRepository = databaseMantraRepository,
onNavigateToRouteAndPopUpInclusive = { chatRoomDetailRoute ->
frostSigningRepository = databaseFrostSigningRepository,
onNavigateToRouteAndPopUpInclusive = { signingRoute ->
// Replace this add screen so back returns to the group rather
// than to a form whose proposal has already gone out.
navController.navigate(
route = chatRoomDetailRoute
route = signingRoute
) {
popUpTo(route) {
inclusive = true
@@ -19,6 +19,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.launch
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.nostr.nip30303.ArtifactEvent
import press.mantra.compose.repository.FrostSigningRepository
import press.mantra.compose.repository.MantraRepository
import press.mantra.compose.ui.view.state.AddArtifactUIState
@@ -30,6 +32,7 @@ class AddArtifactViewModel(
val nostrRepository: NostrRepository,
val chatRepository: ChatRepository,
val mantraRepository: MantraRepository,
val frostSigningRepository: FrostSigningRepository,
): ViewModel() {
var addArtifactUIState: AddArtifactUIState by mutableStateOf(initialAddArtifactUIState)
@@ -52,11 +55,25 @@ class AddArtifactViewModel(
AddArtifactUIState.Loaded(
localChatRoom = localChatRoom,
dialects = mantraRepository.getDialects(localChatRoom.chatRoom.id),
canSign = frostSigningRepository.canSign(chatRoomId),
)
}
}
}
/**
* Asks the group to sign an artifact into its library.
*
* The artifact is not created here and does not exist yet. What goes out is
* a proposal to sign it, and the artifact appears -- on every member's
* device at once, authored by the group's shared key rather than by whoever
* typed it -- when enough members have signed.
*
* That is the difference from submitting one. A submission says "I am
* putting this in front of the group" and the group's only recourse
* afterwards is social. A signature is the group saying it, and it takes a
* quorum to say. A library is the group's, so the second is the honest one.
*/
fun addArtifact(
localChatRoom: LocalChatRoom,
nameField: TextFieldState,
@@ -65,57 +82,65 @@ class AddArtifactViewModel(
dialectId: HexKey?,
visibility: String = MantraRepository.DEFAULT_VISIBILITY,
license: String = MantraRepository.DEFAULT_LICENSE,
onSuccess: (artifactId: String) -> Unit,
onSuccess: (sessionId: String) -> Unit,
onFailure: () -> Unit
) {
localChatRoom.chatRoom.toMlsGroup()?.let { mlsGroup ->
val name = nameField.text.toString()
val url = urlField.text.toString()
val versionLabel = versionLabelField.text.toString()
val name = nameField.text.toString()
val url = urlField.text.toString()
val versionLabel = versionLabelField.text.toString()
// addArtifact requires an existing source dialect; they are defined
// from the group detail screen, not here.
if (name.isBlank() || url.isBlank() || versionLabel.isBlank() || dialectId.isNullOrBlank()) {
onFailure.invoke()
return
}
// An artifact requires an existing source dialect; they are signed into
// existence from the group detail screen, not here.
if (name.isBlank() || url.isBlank() || versionLabel.isBlank() || dialectId.isNullOrBlank()) {
onFailure.invoke()
return
}
// Guard against double submits from repeated FAB taps.
if (isActionPending.value) return
isActionPending.value = true
// Guard against double submits from repeated FAB taps.
if (isActionPending.value) return
isActionPending.value = true
viewModelScope.launch(Dispatchers.IO) {
val artifact = runCatching {
mantraRepository.addArtifact(
localChatRoom = localChatRoom,
name = name,
url = url,
versionLabel = versionLabel,
dialectId = dialectId,
userPublicKey = activeUserPublicKey,
visibility = visibility,
license = license,
)
}.onFailure { error ->
logger.e("Failed to add artifact", error)
}.getOrNull()
viewModelScope.launch(Dispatchers.IO) {
// The version label rides on the artifact rather than following it as
// a second event. The group signs the artifact; a first version
// proposed on its own would cost a second quorum for one form, and
// every device derives the same first version from what was signed.
val artifactEventTemplate = ArtifactEvent.build(
name = name,
url = url,
visibility = visibility,
license = license,
dialectId = dialectId,
versionLabel = versionLabel,
)
if (artifact != null) {
nameField.clearText()
urlField.clearText()
versionLabelField.clearText()
val session = runCatching {
frostSigningRepository.proposeSigning(
localChatRoom = localChatRoom,
userPublicKey = activeUserPublicKey,
kind = artifactEventTemplate.kind,
tags = artifactEventTemplate.tags,
content = artifactEventTemplate.content,
)
}.onFailure { error ->
logger.e("Failed to propose an artifact for signing", error)
}.getOrNull()
viewModelScope.launch(Dispatchers.Main) {
onSuccess.invoke(artifact.id)
}
} else {
viewModelScope.launch(Dispatchers.Main) {
onFailure.invoke()
}
if (session != null) {
nameField.clearText()
urlField.clearText()
versionLabelField.clearText()
viewModelScope.launch(Dispatchers.Main) {
onSuccess.invoke(session.id)
}
} else {
viewModelScope.launch(Dispatchers.Main) {
onFailure.invoke()
}
isActionPending.value = false
}
isActionPending.value = false
}
}
@@ -129,7 +154,8 @@ class AddArtifactViewModel(
initialAddArtifactUIState: AddArtifactUIState = AddArtifactUIState.Loading,
nostrRepository: NostrRepository,
chatRepository: ChatRepository,
mantraRepository: MantraRepository
mantraRepository: MantraRepository,
frostSigningRepository: FrostSigningRepository
): ViewModelProvider.Factory = viewModelFactory {
initializer {
AddArtifactViewModel(
@@ -139,9 +165,10 @@ class AddArtifactViewModel(
initialAddArtifactUIState = initialAddArtifactUIState,
nostrRepository = nostrRepository,
chatRepository = chatRepository,
mantraRepository = mantraRepository
mantraRepository = mantraRepository,
frostSigningRepository = frostSigningRepository
)
}
}
}
}
}
@@ -7,6 +7,13 @@ sealed interface AddArtifactUIState {
data class Loaded(
val localChatRoom: LocalChatRoom,
val dialects: List<MantraDialect> = emptyList(),
/**
* Whether the group holds a shared key. An artifact is signed into
* existence now rather than submitted, so a group without one cannot
* add one here at all.
*/
val canSign: Boolean = false,
): AddArtifactUIState
data class Error(
@@ -15,4 +22,3 @@ sealed interface AddArtifactUIState {
data object Loading: AddArtifactUIState
}