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)

View File

@@ -1,144 +0,0 @@
package press.mantra.compose.database.model
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import press.mantra.compose.nostr.nip30303.ArtifactEvent
import press.mantra.compose.nostr.nip30303.tags.ArtifactVersionMetadataTag
/**
* The first version of an artifact is derived, not delivered.
*
* The group signs an artifact and nothing else, so the version it starts life
* with is not an event anybody sent: every device builds the row for itself out
* of the artifact it already holds. That only works while every device builds
* the *same* row, and nothing about the ids would show it if they stopped —
* they are content hashes, opaque hex either way. What would show is a group
* that quietly disagrees about which version a chapter hangs off, with the
* artifact looking identical on every screen.
*/
class InitialArtifactVersionTest {
private val groupKey = "a".repeat(64)
private val dialectId = "b".repeat(64)
private val chatRoomId = "room"
private fun signedArtifact(
name: String = "In Detention",
versionLabel: String = "1.0",
createdAt: Long = 1_700_000_000,
): ArtifactEvent {
val template = ArtifactEvent.build(
name = name,
url = "example.com",
visibility = "private",
license = "cc",
dialectId = dialectId,
versionLabel = versionLabel,
createdAt = createdAt,
)
// Hashed rather than made up, so two fixtures that differ are two
// different artifacts here for the same reason they would be in the app.
return ArtifactEvent(
id = EventHasher.hashId(
pubKey = groupKey,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content
),
pubKey = groupKey,
createdAt = template.createdAt,
tags = template.tags,
content = template.content,
sig = "d".repeat(128)
)
}
@Test
fun `the derived version is a function of the artifact and nothing else`() {
// Every input has to come off the artifact. Reading the clock here would
// still agree with itself twice in a row -- and disagree between two
// devices that applied the same artifact minutes apart, which is the
// case nobody can reproduce on demand. So the timestamp is checked
// against the artifact's rather than against a second derivation.
val artifact = signedArtifact(createdAt = 1_700_000_000)
val version = MantraArtifactVersion.initialVersionOf(artifact, chatRoomId)
assertNotNull(version)
assertEquals(1_700_000_000, version.createdAt.epochSeconds)
}
@Test
fun `two devices derive the same first version from the same artifact`() {
val artifact = signedArtifact()
val mine = MantraArtifactVersion.initialVersionOf(artifact, chatRoomId)
val theirs = MantraArtifactVersion.initialVersionOf(artifact, chatRoomId)
assertNotNull(mine)
assertEquals(mine.id, theirs?.id)
assertEquals(mine.createdAt, theirs?.createdAt)
}
@Test
fun `an artifact signed at a different moment derives a different version`() {
// The artifact's own timestamp is bound into the derived id, so two
// proposals identical but for when they were made stay two artifacts
// with two first versions rather than colliding on one row.
val first = MantraArtifactVersion.initialVersionOf(signedArtifact(createdAt = 1_700_000_000), chatRoomId)
val second = MantraArtifactVersion.initialVersionOf(signedArtifact(createdAt = 1_700_000_001), chatRoomId)
assertNotNull(first)
assertNotNull(second)
assertNotEquals(first.id, second.id)
}
@Test
fun `the derived version hangs off the artifact and carries what it declared`() {
val artifact = signedArtifact(versionLabel = "First Edition")
val version = MantraArtifactVersion.initialVersionOf(artifact, chatRoomId)
assertEquals(artifact.id, version?.artifactId)
assertEquals("First Edition", version?.versionLabel)
// Authored by whoever authored the artifact -- the group, once signed --
// and unsigned, because nobody signed this.
assertEquals(groupKey, version?.publicKey)
assertEquals("", version?.signature)
}
@Test
fun `the label is bound into the id rather than hung beside it`() {
// Two artifacts alike but for the label must not derive one version
// between them: the id has to come from the whole event, or a group
// renaming a version would leave the row it replaces in place.
val first = MantraArtifactVersion.initialVersionOf(signedArtifact(versionLabel = "1.0"), chatRoomId)
val second = MantraArtifactVersion.initialVersionOf(signedArtifact(versionLabel = "2.0"), chatRoomId)
assertNotNull(first)
assertNotNull(second)
assertNotEquals(first.id, second.id)
}
@Test
fun `an artifact that declares no version derives none`() {
// Artifacts written before the artifact carried its first version.
val declared = signedArtifact()
val silent = ArtifactEvent(
id = declared.id,
pubKey = declared.pubKey,
createdAt = declared.createdAt,
tags = declared.tags.filterNot { it.firstOrNull() == ArtifactVersionMetadataTag.TAG_NAME }
.toTypedArray(),
content = declared.content,
sig = declared.sig
)
assertNull(MantraArtifactVersion.initialVersionOf(silent, chatRoomId))
}
}

View File

@@ -23,6 +23,7 @@ import press.mantra.compose.database.model.MantraArtifact
import press.mantra.compose.database.model.MantraArtifactVersion
import press.mantra.compose.extensions.toHex
import press.mantra.compose.nostr.nip30303.ArtifactEvent
import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
/**
* An artifact the group signed, from proposal to rows, against real FROST.
@@ -227,21 +228,70 @@ class SignedArtifactTest {
assertEquals(dialectId, artifact?.dialectId)
}
/** The second item of the batch, through the same quorum as the first. */
private fun signedInitialVersionEvent(versionLabel: String = "1.0"): ArtifactVersionEvent {
// Built from the unsigned lead, which is what `proposeSigningBatch` hands
// to `dependents` -- the artifact's id is settled at proposal, not at
// signature, which is the only reason a version can name it at all.
val lead = unsignedEventOf(proposalTemplate(versionLabel))
val item = itemOver(unsignedEventOf(ArtifactVersionEvent.initialVersionOf(lead).single()))
val signed = FrostSigningManager.signedEvent(item, groupSignature(item))
return ArtifactVersionEvent(
signed.id, signed.pubKey, signed.createdAt, signed.tags, signed.content, signed.sig
)
}
@Test
fun `the artifact arrives with the first version hanging off it`() {
// Nothing sends this row: each device derives it from the artifact it
// just applied. A chapter attaches to a version rather than to an
// artifact, so an artifact that arrives without one is inert.
val signed = signedArtifactEvent(versionLabel = "First Edition")
val artifact = MantraArtifact.fromArtifactEvent(signed, chatRoomId)
val version = MantraArtifactVersion.initialVersionOf(signed, chatRoomId)
// A chapter attaches to a version rather than to an artifact, so an
// artifact that arrives without one is inert. Both come out of one
// batch, so neither can arrive without the other.
val artifact = MantraArtifact.fromArtifactEvent(
signedArtifactEvent(versionLabel = "First Edition"), chatRoomId
)
val version = MantraArtifactVersion.fromArtifactVersionEvent(
signedInitialVersionEvent(versionLabel = "First Edition"), chatRoomId
)
assertNotNull(version)
assertEquals(artifact?.id, version.artifactId)
assertEquals("First Edition", version.versionLabel)
assertEquals(groupPubKey, version.publicKey)
// Derived, not signed: the group signed the artifact that declares it.
assertEquals("", version.signature)
}
@Test
fun `the first version carries the group's signature over its own id`() {
// The point of signing it rather than deriving it. A derived version was
// authored by the group and signed by nobody -- indistinguishable, on the
// row, from one somebody made up, and the parent of every chapter in it.
val version = MantraArtifactVersion.fromArtifactVersionEvent(
signedInitialVersionEvent(), chatRoomId
)
assertNotNull(version)
assertNotEquals("", version.signature)
assertTrue(
Nip01Crypto.verify(
signature = version.signature.hexToByteArray(),
hash = version.id.hexToByteArray(),
pubKey = version.publicKey.hexToByteArray()
),
"a first version should carry a signature the group's key made over its own id"
)
}
@Test
fun `the version is a second message, signed under its own nonce`() {
// Two items of a batch are two independent FROST instances: k signatures
// over k ids, never one signature stretched over both. A version whose
// signature verified against the artifact's id would mean a nonce had
// been reused, which is how a share is extracted rather than a cosmetic
// mix-up.
val artifact = signedArtifactEvent()
val version = signedInitialVersionEvent()
assertNotEquals(artifact.id, version.id)
assertNotEquals(artifact.sig, version.sig)
}
}

View File

@@ -0,0 +1,160 @@
package press.mantra.compose.nostr.nip30303
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
import press.mantra.compose.nostr.nip30303.tags.ArtifactVersionMetadataTag
/**
* The version an artifact starts life with, built to be signed beside it.
*
* It used to be derived on arrival from the label the artifact carries, because
* proposing it on its own would have cost a second quorum for one form. A batch
* costs one quorum, so it is signed now -- and a chapter hangs off this row,
* which is the reason to care: a version nobody signed is a parent vouched for
* by its own signed children rather than the other way round.
*
* What has to hold is that every input comes off the artifact. The id is not
* settled here -- the signing session hashes it under the group's key -- so an
* input read from anywhere else would not show up as a wrong row. It would show
* up as two devices proposing two different batches, which reads as a batch that
* simply never aggregates.
*/
class InitialArtifactVersionTest {
private val groupKey = "a".repeat(64)
private val dialectId = "b".repeat(64)
/**
* The artifact as the session will sign it: the proposer's fields re-authored
* under the group's key. This is what `proposeSigningBatch` hands to
* `dependents`, so it is what the version has to be built from.
*/
private fun leadArtifact(
versionLabel: String = "1.0",
createdAt: Long = 1_700_000_000,
): Event {
val template = ArtifactEvent.build(
name = "In Detention",
url = "example.com",
visibility = "private",
license = "cc",
dialectId = dialectId,
versionLabel = versionLabel,
createdAt = createdAt,
)
return Event(
id = EventHasher.hashId(
pubKey = groupKey,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content
),
pubKey = groupKey,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content,
sig = ""
)
}
/** The version item as the session would author it, so the accessors read. */
private fun versionOf(artifact: Event): ArtifactVersionEvent? =
ArtifactVersionEvent.initialVersionOf(artifact).singleOrNull()?.let { template ->
ArtifactVersionEvent(
id = EventHasher.hashId(
pubKey = groupKey,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content
),
pubKey = groupKey,
createdAt = template.createdAt,
tags = template.tags,
content = template.content,
sig = ""
)
}
@Test
fun `the version names the artifact it was built from`() {
// The whole reason this takes the lead event rather than a form's fields:
// the id it carries is a hash over the group's key at the room's path, so
// there is no artifact to name until the proposal has been authored.
val artifact = leadArtifact()
assertEquals(artifact.id, versionOf(artifact)?.artifactId())
}
@Test
fun `it carries the label the artifact declared`() {
val artifact = leadArtifact(versionLabel = "First Edition")
assertEquals("First Edition", versionOf(artifact)?.content)
}
@Test
fun `it takes the artifact's own timestamp rather than the clock`() {
// So the batch reads as one act. Reading the clock here would agree with
// itself on one device and disagree between two that proposed the same
// artifact a second apart -- which is a batch nobody can aggregate, and
// the case nobody can reproduce on demand.
val artifact = leadArtifact(createdAt = 1_700_000_042)
assertEquals(1_700_000_042, versionOf(artifact)?.createdAt)
}
@Test
fun `an artifact declares exactly one first version`() {
// Item 0 is the artifact and item 1 is this, and a chapter hangs off
// whichever version it finds. Two would make that a coin toss.
assertEquals(1, ArtifactVersionEvent.initialVersionOf(leadArtifact()).size)
}
@Test
fun `two devices build the same version from the same artifact`() {
// Every signer authors the batch itself and signs the ids it arrives at.
// Devices that disagree here produce partial signatures over different
// messages, and the session stalls rather than saying why.
val artifact = leadArtifact()
assertEquals(versionOf(artifact)?.id, versionOf(artifact)?.id)
}
@Test
fun `artifacts alike but for the label do not share a version`() {
// The label is bound into the id rather than hung beside it, so renaming
// a version cannot leave the row it replaces standing.
val first = versionOf(leadArtifact(versionLabel = "1.0"))
val second = versionOf(leadArtifact(versionLabel = "2.0"))
assertNotEquals(first?.id, second?.id)
}
@Test
fun `an artifact that declares no version proposes none`() {
// Artifacts written before the artifact carried its first version. The
// batch is then one item, which is what an artifact proposal used to be.
val declared = leadArtifact()
val silent = Event(
id = declared.id,
pubKey = declared.pubKey,
createdAt = declared.createdAt,
kind = declared.kind,
tags = declared.tags.filterNot { it.firstOrNull() == ArtifactVersionMetadataTag.TAG_NAME }
.toTypedArray(),
content = declared.content,
sig = declared.sig
)
assertTrue(ArtifactVersionEvent.initialVersionOf(silent).isEmpty())
assertNull(versionOf(silent))
}
}