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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user