feat: sign an artifact's first version with it, not derive it after

A chapter attaches to a version rather than to an artifact, so the first version
is the parent of everything a group later translates. It was not signed. Every
device rebuilt it from the artifact on arrival, which put a row on disk naming
the group as its author and carrying no signature to show for it -- a parent
vouched for by its own signed children rather than the other way round.

The reason was written into both ends: a first version proposed on its own would
cost a second quorum for one form. That is an argument against a second session,
and it stopped being an argument at all once a batch existed. `proposeSigningBatch`
is one ceremony, one approval and one transcript whatever k is.

The same reasoning was already overturned once, for the same shape. A chapter's
chunks were briefly derived from the signed chapter's text for exactly this
reason, and they carry their own signatures now. The artifact version is the case
that was left behind, and it needs the same form: `ArtifactVersionEvent` names the
artifact it is of, and that id is a hash over the group's key at the room's path,
so it cannot be known until the proposal is authored. `initialVersionOf` takes the
lead the session built, mirroring `ChunkEvent.splitOf`, and the artifact is item 0
because a version row whose artifact does not exist yet is a foreign key
violation.

Two things had to move with it, and both would have been silent.

`ChatMessage.applyInnerEvent` no longer derives a version under an artifact. The
derived row and the signed one hash differently -- different author, different
timestamp -- so keeping both would have stood two versions against one artifact
and let a chapter hang off whichever it found.

The `ArtifactVersionEvent` arm no longer writes a chat line. It never used to
reach one: a derived version wrote nothing. Signed, it would have put "Added 1.0
to artifact versions" under every "Added In Detention to artifacts", which is the
noise the chunk arm already declines to make beside a chapter.

An artifact signed before this keeps a version label nothing turns into a row, so
its version does not appear. That is what the chapter's chunks cost too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-06 13:44:28 +02:00
parent dbdff55ee0
commit c8b62e606e
8 changed files with 303 additions and 226 deletions

View File

@@ -779,22 +779,12 @@ 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,
)
)
}
// The version the artifact starts life with is its own
// event, signed in the same batch and applied after this
// one -- see AddArtifactViewModel.addArtifact. It used to
// be derived here instead; deriving it now as well would
// stand a second, unsigned version row against the same
// artifact, since the two hash differently.
ChatMessage(
giftWrapPayloadId = null,
@@ -827,18 +817,15 @@ data class ChatMessage(
)
)
ChatMessage(
giftWrapPayloadId = null,
messageType = "artifactVersion",
marmotGroupEventId = marmotGroupEventId,
marmotInnerEventId = marmotInnerEventId,
senderPublicKey = senderPublicKey,
isUserMessage = isUserMessage,
chatRoomId = groupId,
createdAt = createdAt,
content = "Added ${mantraArtifactVersion.versionLabel} to artifact versions" // TODO: Use artifact name...
)
// No line of its own. A version arrives as the second
// item of the batch that carries its artifact, and the
// artifact has already said so -- the same reason a
// chunk writes no line beside its chapter. While the
// version was derived here rather than signed it wrote
// none either, so this is the transcript standing still
// rather than losing something.
}
null
}
ChapterEvent.KIND -> {
// The chunks the chapter splits into are their own events,

View File

@@ -10,10 +10,8 @@ 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
import kotlin.time.Clock
import kotlin.time.Instant
@@ -84,42 +82,6 @@ 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,

View File

@@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip22Comments.RootScope
import com.vitorpamplona.quartz.nip31Alts.alt
@@ -32,6 +33,47 @@ class ArtifactVersionEvent(
const val KIND = 30301
const val ALT_DESCRIPTION = "ArtifactVersion"
/**
* The first version [artifact] declares, ready to be signed with it.
*
* An artifact and the version it starts life with go to the group as one
* batch, so this is built from the artifact *after* it has been authored
* under the group's key -- [artifact] is the unsigned event the session
* will sign, which is where the id this carries comes from. Building it
* anywhere else would mean naming an artifact id before one exists.
*
* It takes the artifact's own timestamp, so the batch reads as one act
* rather than two events that happen to share a session.
*
* This used to be derived on arrival instead, from the label the
* artifact carries -- the same shape a chapter's chunks were in, and
* abandoned for the same reason. Deriving cost nothing while the
* alternative was a second quorum, and nothing is what it bought: a row
* naming the group as its author with no signature to show for it, which
* a chapter then hangs off. A batch is one quorum, so the version can
* carry the group's signature over its own label.
*
* Empty when the artifact declares no version -- every artifact written
* before it did -- which leaves a batch of one and an artifact with no
* version, exactly as before.
*/
fun initialVersionOf(artifact: Event): List<EventTemplate<ArtifactVersionEvent>> {
val versionLabel = ArtifactEvent(
id = artifact.id,
pubKey = artifact.pubKey,
createdAt = artifact.createdAt,
tags = artifact.tags,
content = artifact.content,
sig = artifact.sig
).versionLabel() ?: return emptyList()
return listOf(
build(content = versionLabel, createdAt = artifact.createdAt) {
addUnique(ArtifactIdTag.assemble(artifact.id))
}
)
}
fun build(
content: String,
createdAt: Long = TimeUtils.now(),

View File

@@ -4,6 +4,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import press.mantra.compose.managers.SharedKeyDerivation
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
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.TranslationArtifactVersionEvent
@@ -52,6 +53,15 @@ object ProposedEvent {
}
)
// Signed alongside the artifact it belongs to rather than on its own,
// so this is almost always read as the second line of a batch of two.
// Named after the artifact rather than the label, because the label is
// the whole of the content and would otherwise be said twice.
ArtifactVersionEvent.KIND -> Summary(
label = "Version of the artifact",
detail = event.content.ifBlank { "Unlabelled" }
)
ChapterEvent.KIND -> Summary(
label = "New chapter",
detail = ChapterEvent(

View File

@@ -20,6 +20,7 @@ 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.nostr.nip30303.ArtifactVersionEvent
import press.mantra.compose.repository.FrostSigningRepository
import press.mantra.compose.repository.MantraRepository
import press.mantra.compose.ui.view.state.AddArtifactUIState
@@ -75,6 +76,10 @@ class AddArtifactViewModel(
* 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.
*
* Two events, one session: the artifact and the version it starts life with,
* which a chapter later hangs off. All-or-nothing, which is right here --
* an artifact with no version is inert, since nothing can attach to it.
*/
fun addArtifact(
localChatRoom: LocalChatRoom,
@@ -103,10 +108,12 @@ class AddArtifactViewModel(
isActionPending.value = true
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.
// The label rides on the artifact and the version is signed beside
// it, in the same batch. A first version proposed on its own would
// cost a second quorum for one form, which is why it used to be
// derived on arrival instead; a batch costs one quorum, so the row a
// chapter hangs off can carry the group's signature rather than
// being rebuilt from the artifact by every device that holds it.
val artifactEventTemplate = ArtifactEvent.build(
name = name,
url = url,
@@ -117,12 +124,15 @@ class AddArtifactViewModel(
)
val session = runCatching {
frostSigningRepository.proposeSigning(
frostSigningRepository.proposeSigningBatch(
localChatRoom = localChatRoom,
userPublicKey = activeUserPublicKey,
kind = artifactEventTemplate.kind,
tags = artifactEventTemplate.tags,
content = artifactEventTemplate.content,
lead = artifactEventTemplate,
// The version names the artifact it is of, and that id is a
// hash over the group's key at the room's path -- neither of
// which this screen knows or should. The artifact comes back
// built, and the version is read off the label it declares.
dependents = ArtifactVersionEvent::initialVersionOf
)
}.onFailure { error ->
logger.e("Failed to propose an artifact for signing", error)