diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt index 7aba6e26..baaaccff 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifactVersion.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifactVersion.kt index 6cd5c209..f9d011f1 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifactVersion.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifactVersion.kt @@ -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, chatRoomId: HexKey, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ArtifactVersionEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ArtifactVersionEvent.kt index ca052f83..7268c87c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ArtifactVersionEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ArtifactVersionEvent.kt @@ -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> { + 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(), diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt index 7d551150..4f25bc13 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt @@ -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( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddArtifactViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddArtifactViewModel.kt index b7e20a84..e576fd52 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddArtifactViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddArtifactViewModel.kt @@ -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) diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/InitialArtifactVersionTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/InitialArtifactVersionTest.kt deleted file mode 100644 index c59e65ff..00000000 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/InitialArtifactVersionTest.kt +++ /dev/null @@ -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)) - } -} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedArtifactTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedArtifactTest.kt index 6aeea827..5cccb1dc 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedArtifactTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedArtifactTest.kt @@ -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) } } diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/InitialArtifactVersionTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/InitialArtifactVersionTest.kt new file mode 100644 index 00000000..258c94a7 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/InitialArtifactVersionTest.kt @@ -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)) + } +}