feat: archive the translated text too, now that the group signs it

The merge brought in two commits that close the gap this feature was written
around, so the allowlist grows from six kinds to eight.

`feat: sign an artifact's first version with it, not derive it after` makes the
version the second item of the artifact's own signing batch. `feat: ask the group
to sign a chunk's translation, not just save it` puts a quorum behind the prose.
Both were done for their own reasons and neither was about the archive, but they
are exactly what the archive was missing: an archive can only carry what its
recipient can check, so a derived version and a member-authored translation could
not travel. A new member got the whole structure and none of the words.

**30301 and 30309 do not go on the end of the list.** The order is the foreign
keys: a version sits between its artifact and the chapters hanging off it, and a
translated chunk hangs off both a source chunk and a translation chapter, so that
one really is last.

**`toArtifactVersionEvent` had the bug this predicted it would.** It emitted
[artifactId, alt] where `build` emits [alt, artifactId], so the id did not
round-trip -- the same fault fixed on `MantraArtifact.toArtifactEvent` in Phase 3,
in the second of the three unused rebuilds, and for the same reason: nothing had
ever called it, so the "tag order matches build" claim in its comment was never
checked. `toTranslationChunkEvent` was already correct. Both now have a
round-trip case, which is what makes the difference between a rebuild that is
right and one that has not been contradicted yet.

**`signedEventsOf` walks two steps further**, emitting each version and the
translation chunks under each translation chapter. A retranslated passage
archives once: the arm that applies a translation chunk drops the one it
supersedes -- newest by the timestamp the group signed at, id breaking a tie --
so what a sender holds, and therefore what travels, is the group's current answer
to each passage rather than its drafts.

**The seeds had to change with it.** Both database tests derived the artifact's
first version by applying the artifact, which is exactly what stopped happening;
they now sign it through `ArtifactVersionEvent.initialVersionOf`, the way the
batch does. That also removes the one exception in the end-to-end assertion:
every archived row is now authored by the room and carries a signature, where the
artifact version used to have to be excused for having neither.

480 tests pass. The plan's Phase 3 table, its built-vs-plan table and its "what
this does not do" section are updated -- what an archive cannot do is down from
two things to one, and the remaining one is that it still cannot make its
recipient able to sign.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-06 15:13:43 +02:00
parent 54091099a9
commit 47aa79ebc7
8 changed files with 253 additions and 130 deletions

View File

@@ -68,12 +68,16 @@ data class MantraArtifactVersion(
id = id,
pubKey = publicKey,
createdAt = createdAt.epochSeconds,
// Tag order matches ArtifactVersionEvent.initialVersionOf, which puts
// the alt first because `build` does, or the event id does not
// round-trip. It was the other way round here for as long as nothing
// called this -- see ArchiveRoundTripTest, which is what calls it now.
tags = TagArrayBuilder<ArtifactVersionEvent>()
.addUnique(
ArtifactIdTag.assemble(artifactId)
AltTag.assemble(ArtifactVersionEvent.ALT_DESCRIPTION)
)
.addUnique(
AltTag.assemble(ArtifactVersionEvent.ALT_DESCRIPTION)
ArtifactIdTag.assemble(artifactId)
)
.build(),
content = versionLabel,

View File

@@ -577,10 +577,12 @@ object ArchiveManager {
.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.
// artifact was signed with, and `ArtifactVersionEvent.initialVersionOf`
// gives it the artifact's own timestamp. The label is still not on the
// artifact row -- `fromArtifactEvent` drops it -- so this is where it
// comes back from. An artifact with no such version is one 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
@@ -592,6 +594,8 @@ object ArchiveManager {
}
versions.forEach { version ->
add(version.toArtifactVersionEvent())
database.mantraChapterDao()
.getChaptersByArtifactVersionId(version.id)
.forEach { chapter ->
@@ -609,7 +613,20 @@ object ArchiveManager {
database.mantraTranslationChapterDao()
.getTranslationChaptersByTranslationArtifactVersionId(translationVersion.id)
.forEach { add(it.toTranslationChapterEvent()) }
.forEach { translationChapter ->
add(translationChapter.toTranslationChapterEvent())
// Only the translation that survived supersession
// is here to be found: retranslating a passage
// changes the text and so the event id, and the
// arm that applies one drops what it replaces. So
// an archive carries a group's current answer to
// each passage rather than its drafts, which is
// the same thing every other member holds.
database.mantraTranslationChunkDao()
.getTranslationChunksByTranslationChapterId(translationChapter.id)
.forEach { add(it.toTranslationChunkEvent()) }
}
}
}
}

View File

@@ -15,11 +15,13 @@ 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
/**
* 30327
@@ -170,35 +172,32 @@ class ArchiveEvent(
* 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:
* Left out for that reason:
*
* - `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.
*
* Two kinds were on that list and are not any more, because the app
* stopped leaving them unsigned. `ArtifactVersionEvent` (30301) used to be
* derived from the signed artifact on arrival, and is now the second item
* of the batch that carries it. `TranslationChunkEvent` (30309) -- the
* translated text itself -- used to be submitted as its author's rumor,
* and is now proposed to the group like everything else. Both are
* therefore checkable, and an archive that left them out would hand a new
* member the whole structure and none of the prose.
*/
private val APPLY_ORDER: List<Kind> = listOf(
DialectEvent.KIND,
ArtifactEvent.KIND,
ArtifactVersionEvent.KIND,
ChapterEvent.KIND,
ChunkEvent.KIND,
TranslationArtifactVersionEvent.KIND,
TranslationChapterEvent.KIND,
TranslationChunkEvent.KIND,
)
/** Every kind an archive may carry. */