feat: rebuild the group's signed record out of the rows it left behind

Phase 3 of docs/member-archive.md. `ArchiveManager.assemble` walks a room's rows,
rebuilds each into the event the group signed, drops anything it cannot prove,
and cuts the rest into pages. Nothing sends one yet.

**The gate found a real bug, which is why it was the gate.** Signed events are
not stored as events -- `FrostSigningManager.complete` applies one and what
survives is a `Mantra*` row -- so an archive has to rebuild them with `toXEvent()`
and stands or falls on that being byte-identical to what was signed. Every
`toXEvent()` in the codebase turned out to be unused in production, written for
exactly this and never called, so the "tag order matches build so the event id
round-trips" comments on them were claims nothing had ever checked.

One was wrong. `MantraArtifact.toArtifactEvent` put the alt tag last where
`ArtifactEvent.build` puts it first, and left out the version metadata tag
altogether -- because that tag is not on the artifact row at all.
`fromArtifactEvent` reads the artifact's own fields and drops the version label,
which `applyInnerEvent` has by then turned into the artifact's first
`MantraArtifactVersion`. So the label is now a parameter, read off the initial
version: the one whose `createdAt` is the artifact's, since `initialVersionOf`
derives it from the same event.

Neither fault would have surfaced as an error. Both produce a well-formed
artifact whose id no longer matches its fields, which every receiver drops as a
forgery, silently, one kind at a time. `ArchiveRoundTripTest` now signs each
archivable kind with a real quorum, files it as a row, rebuilds it and asserts
the signature still covers what comes out -- plus the negative case, that
rebuilding with the wrong version label fails as a forgery rather than as a
mistake, which is why the assembler reads the label rather than defaulting it.

**The allowlist narrows from nine kinds to six, and this is the finding to read.**
Only six of the thirteen nip30303 kinds ever reach a signing session; the rest
travel as member rumors, vouched for by the MLS frame they arrived in and by
nothing that survives leaving it. An artifact version is derived rather than
signed -- which is fine, because applying the archived artifact derives it again
and the chapters hanging off it keep their foreign key. Nothing builds a
`TranslationEvent` at all. The contributor lists have no arm in `applyInnerEvent`
that writes a row.

And `TranslationChunkEvent` -- **the translated text itself** -- is submitted by
`MantraDao.saveTranslation` as its author's rumor, because a translation is one
member's work rather than a group decision. So an archive restores everything a
translation hangs on and not the translation: a new member gets the dialects, the
artifacts, the chapters, the source chunks, which translations exist and their
chapter scaffolding, and none of the prose. That is a real limit rather than a
detail, so it is written into the allowlist's own doc comment, into the plan's
"what this does not do", and into a test named after it -- with the three ways
out sketched and none of them taken here, because the cheapest gives up the
property the rest of this rests on and the best is a product decision about
whether translating is an act of the group or of a member.

**Nothing unverifiable leaves.** Every rebuilt event is checked with
`isSignedByRoom` against the same room id the recipient will use. Not politeness
-- the receiver checks anyway -- but so the page count says what will actually
arrive: a row from a member's rumor is dropped here rather than by the recipient.

**Walked down the tree, not queried per kind.** Only dialects and artifacts have
a by-room query and the rest hang off a parent, and the walk is also what puts an
artifact's version label within reach. Order is settled afterwards by
`inApplyOrder` rather than by the walk, since the walk groups by artifact and the
foreign keys are by kind.

**Paging is greedy against both caps**, because they bind different archives: a
room of one-line dialects hits the count first and a room of chapters hits the
bytes. An event too large for a page of its own is dropped with a log rather than
failing the archive -- a chapter nobody can archive is a hole, a member who gets
nothing is a bigger one.

Assembling only; queueing moved to Phase 5, where the thing that decides when to
send lives. That keeps this testable against a real database with no outbound
path in the way.

Seven tests over a real in-memory database seeded through `applyInnerEvent`
itself, so what is archived is what a member's device really holds rather than
rows built to suit the test: every payload verifies, all six kinds appear exactly
as often as they were signed, the whole archive is in dependency order end to
end, a member's unsigned dialect sitting in the same room is left out, an empty
room archives nothing without failing, and two archives of identical rows do not
share an id -- which is what stops two members answering one request from having
their pages counted towards each other's total.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-06 14:12:29 +02:00
parent 6e04f6c7af
commit acff66a22e
7 changed files with 1107 additions and 33 deletions

View File

@@ -11,6 +11,7 @@ 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.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
@@ -65,12 +66,34 @@ data class MantraArtifact(
override val createdAt: Instant = Clock.System.now(),
override val updatedAt: Instant = createdAt
): OptionalNostrEventEntity, TimestampedEntity {
fun toArtifactEvent(): ArtifactEvent {
/**
* The event the group signed, rebuilt from this row.
*
* [versionLabel] is a parameter because it is not on this row and cannot be.
* `fromArtifactEvent` reads the artifact's own fields and drops the version
* metadata, which `ChatMessage.applyInnerEvent` has by then turned into the
* artifact's first `MantraArtifactVersion`. It was still part of what the
* group put its signature to, so a rebuild without it hashes to a different
* id and produces an event the signature does not cover -- which a receiver
* reads as a forgery, silently. Recover it from the initial version: the one
* whose `createdAt` is this artifact's, since `initialVersionOf` derives it
* from the same event.
*
* Tag order matches [ArtifactEvent.build] exactly -- alt first -- for the
* same reason, and `ArchiveRoundTripTest` is what says so. Both of those were
* wrong here until an archive needed to rebuild an artifact and nothing had
* ever called this.
*/
fun toArtifactEvent(versionLabel: String): ArtifactEvent {
return ArtifactEvent(
id = id,
pubKey = publicKey,
createdAt = createdAt.epochSeconds,
// Tag order matches ArtifactEvent.build so the event id round-trips.
tags = TagArrayBuilder<ArtifactEvent>()
.addUnique(
AltTag.assemble(ArtifactEvent.ALT_DESCRIPTION)
)
.addUnique(UrlTag.assemble(url))
.addUnique(VisibilityTag.assemble(visibility))
.addUnique(LicenseTag.assemble(license))
@@ -78,7 +101,7 @@ data class MantraArtifact(
DialectIdTag.assemble(dialectId)
)
.addUnique(
AltTag.assemble(ArtifactEvent.ALT_DESCRIPTION)
ArtifactVersionMetadataTag.assemble(versionLabel)
)
.build(),
content = name,

View File

@@ -0,0 +1,212 @@
package press.mantra.compose.managers
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.TimeUtils
import press.mantra.compose.database.MantraDatabase
import press.mantra.compose.extensions.toHex
import press.mantra.compose.nostr.archive.ArchiveEvent
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
/**
* Building the group's signed record into pages a member who lacks it can apply.
*
* See docs/member-archive.md. The short of it: a member added after the work was
* done has none of it and never will, because a group-signed event is applied
* locally by each device that took part and never goes on the wire. This is how
* it gets to them.
*
* ### The rows are the archive
*
* Signed events are not stored as events. `FrostSigningManager.complete` applies
* one and what survives is a `Mantra*` row, so every payload here is rebuilt with
* `toXEvent()` and stands or falls on that rebuild being byte-identical to what
* was signed. `ArchiveRoundTripTest` is what says it is, per kind, against a real
* quorum -- and it found two faults in the artifact's rebuild the first time it
* ran, both of which would have shipped payloads that every receiver drops as
* forgeries without a word.
*
* ### Nothing unverifiable leaves
*
* Every rebuilt event is checked with [GroupKeyStateEvent.isSignedByRoom] before
* it is packed, against the same room id the recipient will check it with. That
* is not politeness towards the receiver, who checks anyway. It is what keeps an
* archive honest about its own size: a row that came from a member's rumor
* cannot be archived, and dropping it here rather than letting the recipient
* drop it means the page count says what will actually arrive.
*/
object ArchiveManager {
private const val TAG = "ArchiveManager"
private val logger = Logger.withTag(TAG)
/**
* Every group-signed event this device holds for [chatRoomId], paged and
* addressed to [recipient].
*
* Empty when there is nothing to send -- a room with no shared key, a room
* whose work is all member rumors, or a device that is itself behind. An
* empty result is not an error and the caller should not report one: the
* honest answer to "send them the history" in a room with no signed history
* is nothing.
*/
suspend fun assemble(
database: MantraDatabase,
chatRoomId: String,
recipient: HexKey,
archiveId: String = RandomInstance.bytes(32).toHex(),
createdAt: Long = TimeUtils.now(),
): List<EventTemplate<ArchiveEvent>> {
val rebuilt = signedEventsOf(database, chatRoomId)
val verified = rebuilt.filter { GroupKeyStateEvent.isSignedByRoom(it, chatRoomId) }
if (verified.size != rebuilt.size) {
// Expected rather than alarming: an artifact version is derived
// rather than signed, and a translation is its author's rumor. What
// would be worth looking at is this dropping something the group
// really did sign, which reads as a broken `toXEvent` rather than as
// a missing signature.
logger.d(
"Leaving ${rebuilt.size - verified.size} of ${rebuilt.size} row(s) out of " +
"$chatRoomId's archive: nothing verifiably signed by the room"
)
}
val pages = paginate(ArchiveEvent.inApplyOrder(verified))
if (pages.isEmpty()) {
logger.i("Nothing signed to archive for room $chatRoomId")
return emptyList()
}
logger.i(
"Archiving ${verified.size} event(s) for $chatRoomId as $archiveId, " +
"${pages.size} page(s) for ${recipient.take(8)}"
)
return pages.mapIndexed { index, payloads ->
ArchiveEvent.build(
payloads = payloads,
archiveId = archiveId,
index = index,
count = pages.size,
recipient = recipient,
createdAt = createdAt,
)
}
}
/**
* The room's rows, rebuilt into the events they came from.
*
* Walked down the tree rather than queried per kind, because only dialects
* and artifacts have a by-room query and the rest hang off a parent. The walk
* is also what makes an artifact's version label reachable: it is not on the
* artifact row -- see `MantraArtifact.toArtifactEvent` -- and the version it
* went into is one step away here.
*
* Order does not matter at this point; [ArchiveEvent.inApplyOrder] settles it
* afterwards. What matters is that nothing is missed, so this returns
* everything and the verify filter above decides what can travel.
*/
private suspend fun signedEventsOf(
database: MantraDatabase,
chatRoomId: String,
): List<Event> = buildList {
database.mantraDialectDao().getDialectsByChatRoomId(chatRoomId).forEach {
add(it.toDialectEvent())
}
database.mantraArtifactDao().getArtifactsByChatRoomId(chatRoomId).forEach { artifact ->
val versions = database.mantraArtifactVersionDao()
.getArtifactVersionsByArtifactId(artifact.id)
// The version an artifact starts life with carries the label the
// artifact was signed with, and `initialVersionOf` gives it the
// artifact's own timestamp. A row with none of those is an artifact
// this device cannot rebuild, which is a gap in the archive rather
// than a reason to abandon it.
val versionLabel = versions
.firstOrNull { it.createdAt == artifact.createdAt }
?.versionLabel
if (versionLabel == null) {
logger.w("Artifact ${artifact.id} has no initial version; leaving it out")
} else {
add(artifact.toArtifactEvent(versionLabel = versionLabel))
}
versions.forEach { version ->
database.mantraChapterDao()
.getChaptersByArtifactVersionId(version.id)
.forEach { chapter ->
add(chapter.toChapterEvent())
database.mantraChunkDao()
.getChunksByChapterId(chapter.id)
.forEach { add(it.toChunkEvent()) }
}
database.mantraTranslationArtifactVersionDao()
.getTranslationsByArtifactVersionId(version.id)
.forEach { translationVersion ->
add(translationVersion.toTranslationArtifactVersionEvent())
database.mantraTranslationChapterDao()
.getTranslationChaptersByTranslationArtifactVersionId(translationVersion.id)
.forEach { add(it.toTranslationChapterEvent()) }
}
}
}
}
/**
* [events] cut into pages that fit, keeping the order they arrive in.
*
* Greedy: fill a page until the next event would cross either cap. Both are
* checked because they bind different archives -- a room of one-line dialects
* hits the count first and a room of chapters hits the bytes.
*
* An event too large to share a page with anything is given one of its own.
* One too large for even that is dropped with a log rather than failing the
* archive: a chapter nobody can archive is a hole, and a member who gets
* nothing at all is a bigger one.
*/
private fun paginate(events: List<Event>): List<List<Event>> {
val pages = mutableListOf<List<Event>>()
var page = mutableListOf<Event>()
// The brackets an empty page already costs.
var bytes = 2
events.forEach { event ->
// Plus the comma this event needs if it is not first on its page.
val size = event.toJson().encodeToByteArray().size + 1
if (2 + size > ArchiveEvent.MAX_PAGE_BYTES) {
logger.w(
"Event ${event.id} is $size bytes and will not fit a " +
"${ArchiveEvent.MAX_PAGE_BYTES}-byte page; leaving it out of the archive"
)
return@forEach
}
val full = page.isNotEmpty() &&
(bytes + size > ArchiveEvent.MAX_PAGE_BYTES || page.size >= ArchiveEvent.MAX_PAGE_EVENTS)
if (full) {
pages.add(page)
page = mutableListOf()
bytes = 2
}
page.add(event)
bytes += size
}
if (page.isNotEmpty()) pages.add(page)
return pages
}
}

View File

@@ -15,14 +15,11 @@ import press.mantra.compose.nostr.archive.tags.ArchiveIdTag
import press.mantra.compose.nostr.archive.tags.ArchivePageTag
import press.mantra.compose.nostr.archive.tags.ArchiveRecipientTag
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
import press.mantra.compose.nostr.nip30303.DialectEvent
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.TranslationChapterEvent
import press.mantra.compose.nostr.nip30303.TranslationChunkEvent
import press.mantra.compose.nostr.nip30303.TranslationEvent
/**
* 30327
@@ -165,22 +162,43 @@ class ArchiveEvent(
* what the room signs with, replayed by whoever kept a copy. Nothing but
* this list stops it.
*
* Absent on purpose: the contributor-list kinds (30305, 30307, 30310).
* `ChatMessage.applyInnerEvent` has no arm that writes a row for any of
* them, so archiving them would cost bytes and restore nothing. They
* belong here on the day that changes, and the test that this list is a
* subset of what `applyInnerEvent` handles is what should catch it.
* ### What is missing from it, and why
*
* **Only kinds the group actually signs can be here**, because an archive
* that cannot be verified is one that has to be believed. Six of the
* thirteen nip30303 kinds reach a signing session; the rest travel as
* rumors -- empty signature, member author, vouched for by the MLS frame
* they arrived in and by nothing that survives leaving it.
*
* Left out for that reason, and this is a real limit rather than an
* oversight:
*
* - `ArtifactVersionEvent` (30301). Never signed. The version an
* artifact starts life with is *derived* from the signed artifact by
* `MantraArtifactVersion.initialVersionOf`, which the artifact's own
* arm in `applyInnerEvent` runs -- so archiving the artifact brings its
* version along and the chapters hanging off it keep their foreign
* key. Later versions go through `MantraDao.addArtifactVersion` as
* rumors, and no screen calls it today.
* - `TranslationChunkEvent` (30309) -- **the translated text itself**.
* `MantraDao.saveTranslation` submits it as a member's rumor, because
* a translation is one member's work rather than a group decision.
* So an archive restores everything a translation hangs on and not the
* translation. See docs/member-archive.md for what closing that would
* take; it is not a line in this list.
* - `TranslationEvent` (30311). Nothing builds one; the inbound arm
* exists and no producer does.
* - The contributor lists (30305, 30307, 30310). `applyInnerEvent` has
* no arm that writes a row for any of them, so archiving them would
* cost bytes and restore nothing.
*/
private val APPLY_ORDER: List<Kind> = listOf(
DialectEvent.KIND,
ArtifactEvent.KIND,
ArtifactVersionEvent.KIND,
ChapterEvent.KIND,
ChunkEvent.KIND,
TranslationArtifactVersionEvent.KIND,
TranslationChapterEvent.KIND,
TranslationChunkEvent.KIND,
TranslationEvent.KIND,
)
/** Every kind an archive may carry. */

View File

@@ -179,20 +179,38 @@ class ArchiveEventTest {
// ---- What an archive may carry ---------------------------------------
@Test
fun `the allowlist is every kind an archive can restore, and only those`() {
fun `the allowlist is every kind the group signs, and only those`() {
listOf(
DialectEvent.KIND,
ArtifactEvent.KIND,
ArtifactVersionEvent.KIND,
ChapterEvent.KIND,
ChunkEvent.KIND,
TranslationArtifactVersionEvent.KIND,
TranslationChapterEvent.KIND,
TranslationChunkEvent.KIND,
TranslationEvent.KIND,
).forEach { assertTrue(ArchiveEvent.isArchivable(it), "kind $it should be archivable") }
assertEquals(9, ArchiveEvent.ARCHIVABLE_KINDS.size)
assertEquals(6, ArchiveEvent.ARCHIVABLE_KINDS.size)
}
@Test
fun `the kinds nobody signs are left out, translations included`() {
// The limit worth knowing about before reading anything else here. An
// archive can only carry what the receiver can check, and six of the
// thirteen nip30303 kinds reach a signing session. The rest travel as
// rumors -- empty signature, member author -- vouched for by the MLS
// frame they arrived in and by nothing that survives leaving it.
// The translated text. MantraDao.saveTranslation submits it as the
// member's own rumor, so an archive restores everything a translation
// hangs on and not the translation.
assertFalse(ArchiveEvent.isArchivable(TranslationChunkEvent.KIND))
// Never signed either: derived from the signed artifact on arrival, which
// is what keeps a chapter's foreign key satisfied without archiving it.
assertFalse(ArchiveEvent.isArchivable(ArtifactVersionEvent.KIND))
// Nothing builds one of these at all.
assertFalse(ArchiveEvent.isArchivable(TranslationEvent.KIND))
}
@Test
@@ -235,17 +253,18 @@ class ArchiveEventTest {
// Every one of these is a foreign key in Room, so an archive applied the
// other way round is a constraint violation rather than a wrong answer.
assertTrue(rank(DialectEvent.KIND) < rank(ArtifactEvent.KIND))
assertTrue(rank(ArtifactEvent.KIND) < rank(ArtifactVersionEvent.KIND))
assertTrue(rank(ArtifactVersionEvent.KIND) < rank(ChapterEvent.KIND))
assertTrue(rank(ChapterEvent.KIND) < rank(ChunkEvent.KIND))
assertTrue(rank(ArtifactVersionEvent.KIND) < rank(TranslationArtifactVersionEvent.KIND))
assertTrue(rank(DialectEvent.KIND) < rank(TranslationArtifactVersionEvent.KIND))
assertTrue(rank(TranslationArtifactVersionEvent.KIND) < rank(TranslationChapterEvent.KIND))
assertTrue(rank(ChapterEvent.KIND) < rank(TranslationChapterEvent.KIND))
assertTrue(rank(ChunkEvent.KIND) < rank(TranslationChunkEvent.KIND))
assertTrue(rank(TranslationChapterEvent.KIND) < rank(TranslationChunkEvent.KIND))
assertTrue(rank(TranslationChunkEvent.KIND) < rank(TranslationEvent.KIND))
assertTrue(rank(TranslationArtifactVersionEvent.KIND) < rank(TranslationEvent.KIND))
assertTrue(rank(DialectEvent.KIND) < rank(TranslationArtifactVersionEvent.KIND))
// A chapter and a translation artifact version both hang off an artifact
// *version*, which is not archived: it is derived from the signed
// artifact by the artifact's own arm in applyInnerEvent. So the artifact
// has to land before either of them, and the dependency runs through a
// kind that is not in this list at all.
assertTrue(rank(ArtifactEvent.KIND) < rank(ChapterEvent.KIND))
assertTrue(rank(ArtifactEvent.KIND) < rank(TranslationArtifactVersionEvent.KIND))
// And the numbers really do disagree with the order, which is why this is
// a list rather than a sortedBy { kind }.
@@ -256,22 +275,22 @@ class ArchiveEventTest {
@Test
fun `sorting a page puts what is referenced before what refers to it`() {
val jumbled = listOf(
payload(TranslationEvent.KIND),
payload(TranslationChapterEvent.KIND),
payload(ChunkEvent.KIND),
payload(DialectEvent.KIND),
payload(TranslationArtifactVersionEvent.KIND),
payload(ChapterEvent.KIND),
payload(ArtifactEvent.KIND),
payload(ArtifactVersionEvent.KIND),
)
assertEquals(
listOf(
DialectEvent.KIND,
ArtifactEvent.KIND,
ArtifactVersionEvent.KIND,
ChapterEvent.KIND,
ChunkEvent.KIND,
TranslationEvent.KIND,
TranslationArtifactVersionEvent.KIND,
TranslationChapterEvent.KIND,
),
ArchiveEvent.inApplyOrder(jumbled).map { it.kind }
)

View File

@@ -0,0 +1,351 @@
package press.mantra.compose.nostr.archive
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import fr.acinq.bitcoin.ByteVector
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.PrivateKey
import fr.acinq.bitcoin.crypto.frost.Frost
import fr.acinq.bitcoin.crypto.frost.IndividualNonce
import fr.acinq.bitcoin.crypto.frost.KeyMaterial
import fr.acinq.bitcoin.crypto.frost.SecretNonce
import fr.acinq.bitcoin.crypto.frost.Session
import fr.acinq.bitcoin.crypto.frost.TweakCache
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import press.mantra.compose.database.model.MantraArtifact
import press.mantra.compose.database.model.MantraChapter
import press.mantra.compose.database.model.MantraChunk
import press.mantra.compose.database.model.MantraDialect
import press.mantra.compose.database.model.MantraTranslationArtifactVersion
import press.mantra.compose.database.model.MantraTranslationChapter
import press.mantra.compose.extensions.toHex
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.ChapterEvent
import press.mantra.compose.nostr.nip30303.ChunkEvent
import press.mantra.compose.nostr.nip30303.DialectEvent
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.TranslationChapterEvent
/**
* The assumption the whole archive rests on: a row can be turned back into the
* event the group signed.
*
* Signed events are not stored as events. `FrostSigningManager.complete` applies
* them and what survives is a `Mantra*` row, so an archive has to rebuild each
* one with `toXEvent()` and hope it comes out byte-identical. If it does not,
* the id changes, the signature no longer covers it, and every receiver drops
* the payload as a forgery -- silently, one kind at a time.
*
* That is what the round-trip note on each `toXEvent` claims and what nothing
* asserted until now. Every kind in `ArchiveEvent.ARCHIVABLE_KINDS` is checked
* here against a real FROST quorum, because a signature that verifies is the
* only evidence that the rebuild is faithful -- comparing fields would only
* prove the test and the code agree about which fields matter.
*
* A kind that fails here cannot be archived at all, whatever the allowlist says.
*/
class ArchiveRoundTripTest {
private val participants = 3
private val threshold = 2
/** Stands in for a completed ceremony; nothing here is about the DKG. */
private val keyMaterial: KeyMaterial = Frost.trustedDealerKeygen(
thresholdSecretKey = PrivateKey(
ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1")
),
nParticipants = participants,
threshold = threshold
)
private val room: SharedKeyDerivation.Derived = SharedKeyDerivation.derive(
thresholdPublicKey = keyMaterial.thresholdPublicKey.value.toHex(),
path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH
)
private val tweakCache: TweakCache = room.cache
/** The group's identity, which is also the room's id. */
private val chatRoomId = room.hex
private val dialectId = "b".repeat(64)
private val artifactVersionId = "c".repeat(64)
private val chapterId = "d".repeat(64)
private val translationArtifactVersionId = "e".repeat(64)
/** Exactly what `FrostSigningManager.unsignedEventOf` does. */
private fun unsignedEventOf(template: EventTemplate<*>) = Event(
id = EventHasher.hashId(
pubKey = chatRoomId,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content
),
pubKey = chatRoomId,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content,
sig = ""
)
/** A real quorum signing [eventId], in the manager's order. */
private fun groupSignature(eventId: String): String {
val message = ByteVector(eventId.hexToByteArray())
val signerIds = listOf(0, 1)
val nonces = signerIds.map { signerId ->
SecretNonce.generate(
sessionRandom = ByteVector32("a".repeat(63) + "${signerId + 1}"),
secretShare = keyMaterial.secretShares[signerId],
publicShare = keyMaterial.publicShares[signerId],
tweakedThresholdPublicKey = tweakCache.tweakedPublicKey,
message = message,
extraInput = null
)
}
val signingSession = Session.create(
aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!,
signerIds = signerIds.map { it.toUInt() },
signerPublicShares = signerIds.map { keyMaterial.publicShares[it] },
nParticipants = participants,
threshold = threshold,
tweakCache = tweakCache,
message = message
)
val partials = signerIds.mapIndexed { position, signerId ->
signingSession.sign(
nonces[position].first,
keyMaterial.secretShares[signerId],
signerId.toUInt()
).right!!
}
return signingSession.aggregateSigs(partials).right!!.toByteArray().toHex()
}
/** A template, signed by the group, the way a finished session leaves one. */
private fun signed(template: EventTemplate<*>): Event {
val unsigned = unsignedEventOf(template)
return Event(
id = unsigned.id,
pubKey = unsigned.pubKey,
createdAt = unsigned.createdAt,
kind = unsigned.kind,
tags = unsigned.tags,
content = unsigned.content,
sig = groupSignature(unsigned.id)
)
}
/**
* The claim, for one kind: sign it, file it as a row, rebuild it from the
* row, and the group's signature still covers what comes out.
*/
private fun assertRoundTrips(template: EventTemplate<*>, rebuild: (Event) -> Event?) {
val signedEvent = signed(template)
assertTrue(
GroupKeyStateEvent.isSignedByRoom(signedEvent, chatRoomId),
"the harness itself must produce a verifiable event"
)
val rebuilt = assertNotNull(
rebuild(signedEvent),
"kind ${template.kind} did not survive being turned into a row"
)
// Asserted before the signature so a failure says which half broke: an id
// that moved means the tag order or a field changed in the rebuild, where
// a signature that fails on a matching id would mean something stranger.
assertEquals(
signedEvent.id,
rebuilt.id,
"kind ${template.kind} came back with a different id"
)
assertEquals(
signedEvent.sig,
rebuilt.sig,
"kind ${template.kind} lost the group's signature"
)
assertTrue(
GroupKeyStateEvent.isSignedByRoom(rebuilt, chatRoomId),
"kind ${template.kind} rebuilt into something the group's signature does not cover"
)
}
@Test
fun `a dialect survives the trip through a row`() {
assertRoundTrips(
DialectEvent.build(
name = "isiZulu",
country = "ZA",
language = "zu",
createdAt = 1_700_000_000L
)
) { event ->
MantraDialect.fromDialectEvent(
DialectEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig),
chatRoomId
)?.toDialectEvent()
}
}
@Test
fun `an artifact survives the trip through a row`() {
// The one that did not, when this test was written. Two faults, and the
// second is the interesting one: `toArtifactEvent` put the alt tag last
// where `build` puts it first, and it left out the version metadata
// entirely -- because that tag is not on the row at all. It is consumed
// into the artifact's first MantraArtifactVersion and dropped. So the
// label has to come back from there, which is why it is a parameter.
assertRoundTrips(
ArtifactEvent.build(
name = "In Detention",
url = "https://example.com/in-detention",
visibility = "private",
license = "cc",
dialectId = dialectId,
versionLabel = "1.0",
createdAt = 1_700_000_000L
)
) { event ->
MantraArtifact.fromArtifactEvent(
ArtifactEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig),
chatRoomId
)?.toArtifactEvent(versionLabel = "1.0")
}
}
@Test
fun `an artifact rebuilt with the wrong version label is not the one that was signed`() {
// What the parameter costs if a caller guesses. The label was part of
// what the group put its signature to, so a rebuild carrying a different
// one is a different event -- and it fails as a forgery rather than as a
// mistake, which is why the assembler reads it off the initial version
// rather than defaulting it.
val signedEvent = signed(
ArtifactEvent.build(
name = "In Detention",
url = "https://example.com/in-detention",
visibility = "private",
license = "cc",
dialectId = dialectId,
versionLabel = "1.0",
createdAt = 1_700_000_000L
)
)
val rebuilt = MantraArtifact.fromArtifactEvent(
ArtifactEvent(
signedEvent.id, signedEvent.pubKey, signedEvent.createdAt,
signedEvent.tags, signedEvent.content, signedEvent.sig
),
chatRoomId
)?.toArtifactEvent(versionLabel = "2.0")
assertNotNull(rebuilt)
assertFalse(GroupKeyStateEvent.isSignedByRoom(rebuilt, chatRoomId))
}
@Test
fun `a chapter survives the trip through a row`() {
assertRoundTrips(
ChapterEvent.build(
artifactVersionId = artifactVersionId,
name = "Chapter One",
originalText = "He was a man of parts.",
index = 0,
wordCount = 6,
characterCount = 22,
createdAt = 1_700_000_000L
)
) { event ->
MantraChapter.fromChapterEvent(
ChapterEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig),
chatRoomId
)?.toChapterEvent()
}
}
@Test
fun `a chunk survives the trip through a row`() {
assertRoundTrips(
ChunkEvent.build(
chapterId = chapterId,
text = "He was a man of parts.",
index = 0,
wordCount = 6,
characterCount = 22,
createdAt = 1_700_000_000L
)
) { event ->
MantraChunk.fromChunkEvent(
ChunkEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig),
chatRoomId
)?.toChunkEvent()
}
}
@Test
fun `a translation artifact version survives the trip through a row`() {
assertRoundTrips(
TranslationArtifactVersionEvent.build(
artifactVersionId = artifactVersionId,
dialectId = dialectId,
name = "isiZulu",
visibility = "private",
license = "cc",
createdAt = 1_700_000_000L
)
) { event ->
MantraTranslationArtifactVersion.fromTranslationArtifactVersionEvent(
TranslationArtifactVersionEvent(
event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig
),
chatRoomId
)?.toTranslationArtifactVersionEvent()
}
}
@Test
fun `a translation chapter survives the trip through a row`() {
assertRoundTrips(
TranslationChapterEvent.build(
translationArtifactVersionId = translationArtifactVersionId,
chapterId = chapterId,
index = 0,
createdAt = 1_700_000_000L
)
) { event ->
MantraTranslationChapter.fromTranslationChapterEvent(
TranslationChapterEvent(
event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig
),
chatRoomId
)?.toTranslationChapterEvent()
}
}
@Test
fun `every archivable kind has a round-trip test above`() {
// The list and this file have to move together. A kind added to the
// allowlist without a case here is one nobody has checked can be rebuilt,
// and the failure is silent: the receiver drops it as a forgery.
assertEquals(
6,
ArchiveEvent.ARCHIVABLE_KINDS.size,
"an archivable kind was added or removed; add or remove its round-trip case"
)
}
}

View File

@@ -0,0 +1,386 @@
package press.mantra.compose.managers
import androidx.room3.Room
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import fr.acinq.bitcoin.ByteVector
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.PrivateKey
import fr.acinq.bitcoin.crypto.frost.Frost
import fr.acinq.bitcoin.crypto.frost.IndividualNonce
import fr.acinq.bitcoin.crypto.frost.KeyMaterial
import fr.acinq.bitcoin.crypto.frost.SecretNonce
import fr.acinq.bitcoin.crypto.frost.Session
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import kotlin.time.Instant
import kotlinx.coroutines.runBlocking
import press.mantra.compose.database.MantraDatabase
import press.mantra.compose.database.builder.getRoomDatabase
import press.mantra.compose.database.model.ChatMessage
import press.mantra.compose.database.model.ChatRoom
import press.mantra.compose.database.model.MantraDialect
import press.mantra.compose.database.model.NostrEvent
import press.mantra.compose.database.model.Profile
import press.mantra.compose.extensions.toHex
import press.mantra.compose.nostr.archive.ArchiveEvent
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
import press.mantra.compose.nostr.nip30303.ArtifactEvent
import press.mantra.compose.nostr.nip30303.ChapterEvent
import press.mantra.compose.nostr.nip30303.ChunkEvent
import press.mantra.compose.nostr.nip30303.DialectEvent
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.TranslationChapterEvent
/**
* Assembling a room's archive out of the rows a device actually holds.
*
* The seed is the real inbound path -- `ChatMessage.applyInnerEvent`, the same
* call a finished signing session makes -- so what is archived here is what a
* member's database really contains rather than rows built to suit the test.
* That matters because the whole risk in this direction is a rebuild that
* differs from what was signed, and rows assembled by hand would hide it.
*/
class ArchiveAssemblyJvmTest {
private val db: MantraDatabase = getRoomDatabase(Room.inMemoryDatabaseBuilder<MantraDatabase>())
@AfterTest
fun closeDb() = db.close()
private val participants = 3
private val threshold = 2
private val keyMaterial: KeyMaterial = Frost.trustedDealerKeygen(
thresholdSecretKey = PrivateKey(
ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1")
),
nParticipants = participants,
threshold = threshold
)
private val room: SharedKeyDerivation.Derived = SharedKeyDerivation.derive(
thresholdPublicKey = keyMaterial.thresholdPublicKey.value.toHex(),
path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH
)
/** The room's id and the key it signs as are one value. */
private val chatRoomId = room.hex
private val newMember = "9".repeat(64)
private fun groupSignature(eventId: String): String {
val message = ByteVector(eventId.hexToByteArray())
val signerIds = listOf(0, 1)
val nonces = signerIds.map { signerId ->
SecretNonce.generate(
sessionRandom = ByteVector32("a".repeat(63) + "${signerId + 1}"),
secretShare = keyMaterial.secretShares[signerId],
publicShare = keyMaterial.publicShares[signerId],
tweakedThresholdPublicKey = room.cache.tweakedPublicKey,
message = message,
extraInput = null
)
}
val signingSession = Session.create(
aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!,
signerIds = signerIds.map { it.toUInt() },
signerPublicShares = signerIds.map { keyMaterial.publicShares[it] },
nParticipants = participants,
threshold = threshold,
tweakCache = room.cache,
message = message
)
val partials = signerIds.mapIndexed { position, signerId ->
signingSession.sign(
nonces[position].first,
keyMaterial.secretShares[signerId],
signerId.toUInt()
).right!!
}
return signingSession.aggregateSigs(partials).right!!.toByteArray().toHex()
}
/** A template as a finished session leaves it: authored and signed by the room. */
private fun signed(template: EventTemplate<*>): Event {
val id = EventHasher.hashId(
pubKey = chatRoomId,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content
)
return Event(
id = id,
pubKey = chatRoomId,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content,
sig = groupSignature(id)
)
}
/** What `FrostSigningManager.applySignedEvent` does, through the same call. */
private suspend fun apply(template: EventTemplate<*>): Event {
val event = signed(template)
ChatMessage.applyInnerEvent(
database = db,
groupId = chatRoomId,
event = event,
marmotGroupEventId = null,
marmotInnerEventId = null,
senderPublicKey = chatRoomId,
isUserMessage = false,
createdAt = Instant.fromEpochSeconds(event.createdAt)
)
return event
}
/** ChatRoom -> Profile -> NostrEvent, the foreign key chain a room hangs off. */
private suspend fun seedRoom() {
val nostrEventId = "c".repeat(64)
db.nostrEventDao().upsert(
NostrEvent(
id = nostrEventId,
pubKey = newMember,
kind = 0,
tags = emptyArray(),
content = "{}",
sig = "0".repeat(128),
)
)
db.profileDao().upsert(
Profile(publicKey = newMember, userName = "member", nostrEventId = nostrEventId)
)
db.chatRoomDao().upsert(
ChatRoom(
id = chatRoomId,
userPublicKey = newMember,
subject = "#admins",
description = null,
mlsGroupState = null,
)
)
}
/** A room with one artifact, one chapter, two chunks and one translation. */
private suspend fun seedSignedWork() {
seedRoom()
val dialect = apply(
DialectEvent.build(name = "isiZulu", country = "ZA", language = "zu", createdAt = 1_700_000_000L)
)
val artifact = apply(
ArtifactEvent.build(
name = "In Detention",
url = "https://example.com/in-detention",
visibility = "private",
license = "cc",
dialectId = dialect.id,
versionLabel = "1.0",
createdAt = 1_700_000_010L
)
)
// Derived from the artifact rather than signed, which is exactly why it
// is not archived and why its label has to be read back off this row.
val version = assertNotNull(
db.mantraArtifactVersionDao().getArtifactVersionsByArtifactId(artifact.id).firstOrNull(),
"applying the artifact should have derived its first version"
)
val chapter = apply(
ChapterEvent.build(
artifactVersionId = version.id,
name = "Chapter One",
originalText = "He was a man of parts. He had many.",
index = 0,
wordCount = 8,
characterCount = 35,
createdAt = 1_700_000_020L
)
)
apply(
ChunkEvent.build(
chapterId = chapter.id,
text = "He was a man of parts.",
index = 0,
wordCount = 6,
characterCount = 22,
createdAt = 1_700_000_030L
)
)
apply(
ChunkEvent.build(
chapterId = chapter.id,
text = "He had many.",
index = 1,
wordCount = 3,
characterCount = 12,
createdAt = 1_700_000_031L
)
)
val translationVersion = apply(
TranslationArtifactVersionEvent.build(
artifactVersionId = version.id,
dialectId = dialect.id,
name = "isiZulu",
visibility = "private",
license = "cc",
createdAt = 1_700_000_040L
)
)
apply(
TranslationChapterEvent.build(
translationArtifactVersionId = translationVersion.id,
chapterId = chapter.id,
index = 0,
createdAt = 1_700_000_050L
)
)
}
private suspend fun archivedPayloads(): List<Event> =
ArchiveManager.assemble(db, chatRoomId, newMember)
.flatMap { assertNotNull(ArchiveEvent.decodePage(it.content)) }
@Test
fun `every payload in an archive is one the room signed`() = runBlocking {
seedSignedWork()
val payloads = archivedPayloads()
assertTrue(payloads.isNotEmpty(), "a room with signed work archives something")
payloads.forEach {
assertTrue(
GroupKeyStateEvent.isSignedByRoom(it, chatRoomId),
"kind ${it.kind} (${it.id.take(8)}) was archived without a signature the room can be checked for"
)
}
}
@Test
fun `an archive holds every kind the group signed, and each of them once`() = runBlocking {
seedSignedWork()
val byKind = archivedPayloads().groupBy { it.kind }
assertEquals(1, byKind[DialectEvent.KIND]?.size)
assertEquals(1, byKind[ArtifactEvent.KIND]?.size)
assertEquals(1, byKind[ChapterEvent.KIND]?.size)
assertEquals(2, byKind[ChunkEvent.KIND]?.size)
assertEquals(1, byKind[TranslationArtifactVersionEvent.KIND]?.size)
assertEquals(1, byKind[TranslationChapterEvent.KIND]?.size)
assertEquals(ArchiveEvent.ARCHIVABLE_KINDS, byKind.keys)
}
@Test
fun `an archive is in dependency order across its whole length`() = runBlocking {
seedSignedWork()
val ranks = archivedPayloads().map { assertNotNull(ArchiveEvent.applyRank(it.kind)) }
assertEquals(ranks.sorted(), ranks, "an archive out of order is a foreign key violation")
}
@Test
fun `a member's own rumor is not part of the group's record`() = runBlocking {
seedSignedWork()
// What `MantraDao.addDialect` writes: the member's key, no signature. It
// is real work in the room and the group never signed it, so it cannot be
// handed to somebody who has no way to check it.
db.mantraDialectDao().upsert(
MantraDialect(
id = "f".repeat(64),
publicKey = newMember,
name = "seSotho",
country = "ZA",
language = "st",
signature = "",
chatRoomId = chatRoomId,
createdAt = Instant.fromEpochSeconds(1_700_000_060L),
)
)
assertEquals(
2,
db.mantraDialectDao().getDialectsByChatRoomId(chatRoomId).size,
"the rumor should be on disk"
)
assertEquals(
1,
archivedPayloads().count { it.kind == DialectEvent.KIND },
"the unsigned dialect should not have been archived"
)
}
@Test
fun `a room with nothing signed archives nothing, and says so rather than failing`() = runBlocking {
seedRoom()
assertEquals(emptyList(), ArchiveManager.assemble(db, chatRoomId, newMember))
}
@Test
fun `an archive of one page still says it is one of one`() = runBlocking<Unit> {
seedSignedWork()
val pages = ArchiveManager.assemble(db, chatRoomId, newMember)
assertEquals(1, pages.size, "this much work fits one page")
val page = ArchiveEvent(
id = "d".repeat(64),
pubKey = chatRoomId,
createdAt = pages.single().createdAt,
tags = pages.single().tags,
content = pages.single().content,
sig = ""
)
assertEquals(0, page.page()?.index)
assertEquals(1, page.page()?.count)
assertEquals(newMember, page.recipient())
assertNotNull(page.archiveId())
}
@Test
fun `two archives of the same rows do not share an id`() = runBlocking {
seedSignedWork()
fun idOf(template: EventTemplate<ArchiveEvent>) = ArchiveEvent(
id = "d".repeat(64),
pubKey = chatRoomId,
createdAt = template.createdAt,
tags = template.tags,
content = template.content,
sig = ""
).archiveId()
// Two members answering one request is the ordinary case, and their page
// counts differ whenever their databases do. Sharing an id would let one
// archive's pages be counted towards the other's total.
assertTrue(
idOf(ArchiveManager.assemble(db, chatRoomId, newMember).single()) !=
idOf(ArchiveManager.assemble(db, chatRoomId, newMember).single())
)
}
}

View File

@@ -265,9 +265,13 @@ array containing a non-event is refused whole.
`ArchiveManager.assemble(database, chatRoomId, recipient): List<EventTemplate<*>>`
Read every group-signed event this device holds for the room, order it, pack it
into pages, and queue each page as a `MarmotInnerEvent` -- the ordinary outbound
path, nothing new.
Read every group-signed event this device holds for the room, order it, and pack
it into pages.
**Assembling only.** Queueing each page as a `MarmotInnerEvent` moved to Phase 5,
where the thing that decides *when* to send one lives. Splitting them keeps this
phase testable against a real database with no outbound path in the way, and
keeps the decision about transcript lines next to the decision about triggers.
### Where the events come from
@@ -283,6 +287,44 @@ still verifies, that entity cannot be archived at all, and it is better to find
out in an afternoon than in Phase 8. A round-trip test per kind, over rows
produced by a real signing session, is the gate on the rest of this work.
**It was right to run it first.** Every `toXEvent()` in the codebase turned out
to be unused in production -- written for exactly this and never called, so the
"tag order matches build so the event id round-trips" comments on them were
claims nothing had checked. One was wrong. `MantraArtifact.toArtifactEvent` put
the alt tag last where `ArtifactEvent.build` puts it first, *and* left out the
version metadata tag entirely -- because that tag is not on the artifact row at
all. `fromArtifactEvent` reads the artifact's own fields and drops the version
label, which `applyInnerEvent` has by then turned into the artifact's first
`MantraArtifactVersion`. So the label comes back as a parameter, read off the
initial version -- the one whose `createdAt` is the artifact's, since
`initialVersionOf` derives it from the same event.
Neither fault would have shown up as an error. Both produce a well-formed
artifact whose id no longer matches its fields, which every receiver drops as a
forgery, silently, one kind at a time.
### What is actually archivable, which is less than it looks
Only six of the thirteen nip30303 kinds ever reach a signing session. The rest
travel as rumors -- empty signature, member author, vouched for by the MLS frame
they arrived in and by nothing that survives leaving it -- so they cannot be put
in front of somebody who has no way to check them.
| kind | | why |
|---|---|---|
| 30304 Dialect, 30300 Artifact, 30302 Chapter, 30303 Chunk, 30306 TranslationArtifactVersion, 30308 TranslationChapter | archivable | proposed through `proposeSigning`/`proposeSigningBatch` |
| 30301 ArtifactVersion | no | never signed; derived from the signed artifact on arrival, which is what keeps a chapter's foreign key satisfied without archiving it |
| 30309 TranslationChunk | no | **the translated text itself.** `MantraDao.saveTranslation` submits it as the member's own rumor |
| 30311 Translation | no | nothing builds one; the inbound arm exists and no producer does |
| 30305, 30307, 30310 contributor lists | no | `applyInnerEvent` has no arm that writes a row for any of them |
The second row of "no" is the one that matters and it is not a detail: **an
archive restores everything a translation hangs on and not the translation.** A
new member gets the dialects, the artifacts, the chapters, the source chunks,
which translations exist and their chapter scaffolding -- the whole structure,
enough to start translating -- and none of the translated text. See
[What this does not do](#what-this-does-not-do).
### Ordering
Room enforces the shape, so an archive out of order is a foreign key violation
@@ -574,6 +616,29 @@ general answer for the reason
[frost-batch-signing.md](./frost-batch-signing.md#appendix--what-was-considered-and-rejected)
gives for manifests. Worth revisiting once anything depends on completeness.
**The translated text is not archived.** The largest gap, and it follows from the
same rule everything else here follows from. A translation chunk is submitted by
`MantraDao.saveTranslation` as the member's own rumor, because a translation is
one member's work rather than a group decision -- so it carries no signature, and
an archive carrying it would be asking its recipient to believe whoever sent it.
A new member therefore receives the entire structure and none of the prose.
Three ways out, in increasing order of how much they cost:
- **Send them anyway, marked unverified**, and let the reader see which rows
came with a group signature and which came on one member's word. Cheap, and it
gives up the property that makes the rest of this safe, so it needs its own
screen language rather than a quiet inclusion.
- **Corroborate.** Every member's archive is an independent copy, so a
translation two members' archives agree on is a claim two devices make. That
is a real strengthening and it needs a second archive to compare against,
which the request path already makes ordinary.
- **Sign them.** The group already puts a quorum behind a chapter and its
chunks; putting one behind a translation would make it archivable like
everything else. It is also a product decision about whether translating is an
act of the group or of a member, which is not a decision this document gets to
make.
**The chat is gone and stays gone.** By design, restated here because it is the
first thing a new member will notice and the archive is what makes them expect
otherwise.