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