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

@@ -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.