feat: apply an archive a member is sent, and sweep what arrived too early
Phase 4 of docs/member-archive.md, and the half where the security lives. A member who holds no share, took part in no signing session and cannot decrypt a word of the room's history now ends up with the same rows as everybody else -- and gets there without trusting whoever sent them. **Intercepted in `fromGroupEventResult`, not in `applyInnerEvent`.** An archive is neither a document nor a submission, and deciding whether to act on one needs the active key, which `applyInnerEvent` has no business knowing. That is the same reason the gift wrap above it is handled there, so it sits next to it. **Verification per payload, framing per page.** A forged payload costs itself and nothing else -- the rule `MarmotInboundManager` already uses for a forged direct message, and for the same reason: this runs inside the inbound transaction and one bad event must not take the room down with it. Refusing the whole page would also let a single forgery deny an entire archive. The page's own framing stays all-or-nothing, because a page that will not parse has lost the thing that says what it contains. **The allowlist runs before the signature check, and it is not a formality.** Verification admits an event to the apply path on the strength of the group's signature, which makes every kind the group has ever signed replayable by any member at any time. There is a test that puts a genuine, still-verifying `GroupKeyStateEvent` in a hand-rolled page -- `ArchiveEvent.build` refuses to make one, which is the outbound half of the same rule -- and asserts the receiver's key state does not move. **The chat line is dropped, deliberately.** `ChatMessage` has an `autoGenerate` primary key, so there is no id to dedupe on and every applied payload would mint a new row: a synthetic transcript dated now, and another one on every pass of the sweep. The archive restores the work. The conversation is forward secret and stays gone. **A device that is not the named recipient does nothing.** It can read the page -- it is an ordinary group message, and it is the group's own history -- but it already holds the work, and re-applying would rewrite every one of its rows to point at an archive page rather than at the event that introduced it. That is also what bounds the sweep: only the member being caught up ever builds the list. **The sweep needs no table.** Pages arrive over relays in no order, so page 3 can land before page 2 and its chunks have no chapter to hang off. Those throw a foreign key violation and would be lost -- except the inbound path already stores every inner event it decrypts, so re-reading them is the same shape `FrostSigningManager.replayStoredMessages` has, for the same reason: nothing was lost, it just had nowhere to go at the time. Two things about the loop, the second found by a test: Progress is measured by *failures falling*, not by rows written. "Repeat while a pass applied something" does not terminate, because every write is an upsert and succeeds forever. What strictly decreases is the count that threw. And the result is the last pass rather than the sum of them. Accumulating counts a payload once per pass it survived and reports failures a later pass went on to fix, so `failed > 0` stops meaning "still missing" -- which is the only question a caller asks it. Caught by strengthening the out-of-order test to assert that the page completing an archive leaves nothing behind, rather than only that the rows matched: without that, the test passed while reporting fourteen failures on a fully converged database. Seven tests over two real databases with the pages carried by hand. The one that matters puts four forgeries in a page beside one honest dialect -- the room's id as author with a made-up signature, a real quorum of another group, an event edited after signing, and a member's own rumor, which is what everything on the wire looks like today -- and asserts the receiver ends with exactly the honest one. The rest: a full catch-up matches the sender row for row with the group's signature intact, pages delivered backwards converge and are asserted to have really failed first so the test cannot pass for the wrong reason, an archive files no chat lines, a bystander applies none of it, and applying the same archive twice changes nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -429,9 +429,20 @@ database.marmotInnerEventDao()
|
||||
```
|
||||
|
||||
Re-apply every stored page for the room, oldest first, after each new page
|
||||
arrives; repeat while a pass applies something it did not apply before; stop when
|
||||
a pass applies nothing. Everything in it is an `upsert` keyed on the event id, so
|
||||
a re-run is free and a converged archive costs one no-op pass.
|
||||
arrives. Everything in it is an `upsert` keyed on the event id, so a re-run is
|
||||
free and a converged archive costs one no-op pass.
|
||||
|
||||
**Progress is falling failures, not rows written.** "Repeat while a pass applies
|
||||
something new" is the obvious loop condition and it does not terminate: an upsert
|
||||
succeeds every time, so every pass applies something forever. What strictly
|
||||
decreases is the number of payloads that threw. A pass that fails fewer than the
|
||||
last one learned something; a pass that does not is as far as these pages get.
|
||||
|
||||
**And the answer is the last pass, not the sum of them.** Accumulating counts a
|
||||
payload once per pass it survived and reports failures that a later pass went on
|
||||
to fix, so `failed > 0` stops meaning "still missing" -- which is exactly the
|
||||
question the caller is asking. Found by asserting that the page completing an
|
||||
out-of-order archive leaves nothing behind, which failed against the sum.
|
||||
|
||||
Only the recipient sweeps, which is what bounds it: the members who skip apply
|
||||
never build the list.
|
||||
|
||||
Reference in New Issue
Block a user