Commit Graph

8 Commits

Author SHA1 Message Date
Kgothatso Ngako
47aa79ebc7 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>
2026-09-06 15:13:43 +02:00
Kgothatso Ngako
f3984e838c test: prove the catch-up row by row, and say what an old build makes of a page
Phases 8 and 9 of docs/member-archive.md. The tests ran in the phases where the
code they cover first existed -- the way the batch-signing note's did -- so this
is what was missing from them, plus the rollout note, plus the plan marked built.

**Compared row by row, not by count.** The end-to-end test asserted the two
databases held the same *number* of artifacts, chapters and chunks. That is not
the claim: two databases can hold the same counts and disagree about every row,
and a rebuild that lost the group's signature -- or re-authored a row as whoever
sent it -- would pass a count and fail the only thing an archive is for. It now
compares `(id, author, signature)` per row across every archived kind, and then
asserts each one is authored by the room and carries a signature.

The artifact version is the one exception, and it has to be: nobody signs it, it
is derived from the signed artifact on arrival. Which is exactly why it is not
archived, and why a chapter's foreign key survives without it.

**An old build does not ignore an archive page, it renders it.** Phase 9's first
draft said an old build "files it as unsupported, exactly as it does today for
anything it does not know" -- true, and it reads better than it lives. An
unsupported row's content is `event.toJson()` and it renders as an ordinary chat
bubble, so every member on an old build sees each archive page as a raw-JSON
bubble of up to `MAX_PAGE_BYTES`, once per page.

Nothing breaks and nothing is lost, but a group mid-upgrade gets a genuinely
unpleasant transcript, and that is worth knowing before the first archive goes
out. So the rollout rule is stated rather than implied: the receiving half ships
safely on its own -- phases 1-4 send nothing -- and no member starts sending
until every member understands kind 30327. The mitigation if that ever proves
unacceptable is the one the appendix rejects for other reasons, and it is named
there so the trade can be weighed rather than rediscovered.

**The plan is marked built**, with a table of the five places the implementation
chose differently from the plan and why: nine archivable kinds became six, a
count cap that could never fire, queueing moved a phase later, a re-read that was
never needed, and the rollout note above. Phase 8 also records the three tests
that were not in the first draft, each written because something passed for the
wrong reason -- a cap that could not fire, an out-of-order test on an archive
that was never out of order, and a sweep whose "still missing" count included
failures a later pass had already fixed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 14:37:23 +02:00
Kgothatso Ngako
a315b86918 feat: say in the transcript that a member is being caught up
Phase 7 of docs/member-archive.md, in part. Three chat types --
`TYPE_ARCHIVE_REQUESTED`, `TYPE_ARCHIVE_SENT`, `TYPE_ARCHIVE_RECEIVED` -- so a
room that fills itself in explains itself once.

Without this the archive is entirely silent by design: it files no line per
applied payload, because `ChatMessage` has an `autoGenerate` primary key and
every payload would mint a fresh row on every pass of the sweep. The result was a
member joining a working group and watching a room populate with no account of
where any of it came from, which is worse than the noise it avoided.

**One line per archive, not per page.** The received line is written when the
request stamp is cleared, which is as close as this can get: an archive's pages
are not distinguishable from each other at apply time, and clearing the stamp is
exactly the moment a catch-up stops being pending. There is a test that delivers
a payload per page, backwards, so the sweep runs repeatedly over many pages, and
asserts the transcript holds two lines.

**A push behind a Welcome writes nothing**, because the room was never asked. It
lands before the member has opened the room, and "caught up on work you have not
seen yet" is a line about nothing. Also tested.

**The received line names no sender.** An archive can be assembled from pages
sent by more than one member, so attributing the catch-up to one would be a guess
dressed as a fact. The sent line does name its recipient, written into the
content the way the invite line writes one -- which does not follow a rename, and
is the accepted cost for a line about something that happened once.

**Content is whole sentences**, so these stay out of the AUTHORED sets and
nothing prefixes a name to them. And they are added to `ARCHIVE_TYPES` with a
matching arm in the transcript, because the failure mode for a missed set is
silent: the line renders as a chat bubble, looking exactly like a member having
said "Caught up on 12 items". Icons per type rather than the `PanTool` fallback.

**Two items from this phase are deliberately not done**, rather than written
without the app in front of me: the banner saying a room is catching up, and a
"Send history" action on the member row. The first is UI state plumbed through a
view model into a layout and the transcript line covers the same ground; the
second is a convenience, since both real paths are already automatic. Both are
written up in the plan as outstanding, along with the thing this phase was also
meant to say and does not: that an archive does not make its recipient able to
sign, and does not carry the translated text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 14:34:19 +02:00
Kgothatso Ngako
6d8866c2b0 feat: offer a new member the group's work alongside their welcome
Phase 6 of docs/member-archive.md, and deliberately the phase after the one that
makes it unnecessary. `deliveryWelcome` now queues an archive for the member
being invited, so in the ordinary case they have the group's signed work before
they think to ask for it.

**This is a latency optimisation, not the mechanism.** A page queued behind the
Welcome is not delivered after it: they are different transports -- a relay-borne
gift wrap and a kind:445 -- with no ordering between them, and a page that
overtakes the Welcome is from an epoch ahead of the invitee's, so
`MarmotInboundManager` drops it outright rather than deferring it. Nothing
retries and the inviter sees a success. That is the failure in
docs/marmot-membership.md wearing new clothes, and the only thing that closes it
is the invitee asking once they are demonstrably in the group, which Phase 5
already does on their first open of the room.

So nothing here reports failure to the inviter. A push that does not land is the
ordinary case the pull exists for, and it sits inside `deliveryWelcome`'s own
catch alongside the Welcome it rides behind. A room with nothing signed queues
nothing and still invites.

**One call, two occasions.** `ArchiveManager.answer` becomes `sendTo`: answering
a request and pushing behind a Welcome are the same operation and differ only in
who decided, so it is named for what it does rather than for either occasion.

Also corrects the plan. Phase 6 claimed the room had to be re-read between the
invite and the assembly, for the same reason sequential invites re-read it. It
does not -- that rule is about the MLS snapshot a commit is built on, and this
runs downstream of the commit over `Mantra*` rows, which no commit touches.

Two tests against a real `deliveryWelcome`: inviting into a room with signed work
queues exactly one archive page addressed to the invitee, and inviting into a
room with none queues no page while still writing the Welcome's gift wrap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 14:29:11 +02:00
Kgothatso Ngako
ed4410b972 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>
2026-09-06 14:19:15 +02:00
Kgothatso Ngako
acff66a22e 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>
2026-09-06 14:12:29 +02:00
Kgothatso Ngako
6e04f6c7af feat: give the group's signed record an envelope it can travel in
Phase 2 of docs/member-archive.md. Two kinds, three tags, a codec and two caps.
Nothing sends or applies one yet -- that is phases 3 and 4 -- so this changes no
behaviour at all.

`ArchiveEvent` (30327) carries a page of the group's signed events, each whole,
keeping its own id, author and signature so the receiver checks it rather than
believing it. `ArchiveRequestEvent` (30328) is how a device with none of it asks.

**Why not one `SubmissionEvent` per event.** The envelope fits and the meaning
does not. A submission is an *act* -- this member is putting this event in front
of this group -- and an archive asserts nothing; it re-delivers what the group
already agreed. On one kind a four-hundred-event backfill is indistinguishable
from four hundred new submissions and every device has to guess which it is
reading. It would also be one inner event and one kind:445 per payload where a
page is one, and the submission arm of `applyInnerEvent` files a chat line per
payload, which an archive must not.

**Why 3032x and not 30313.** 30313 is free beside the nip30303 document kinds
and is not used, on `FrostSigningEvents`' own advice: the DKG's 30310-30316
already overlap that range and are told apart only by living in NIP-17 gift wraps
instead, which it calls "an accident of routing rather than a decision, and the
next family added should not rely on it." This is that next family. 30327 is also
the right neighbourhood on the merits, next to `GroupKeyStateEvent` at 30326 --
an archive is a statement about the record rather than a document kind.

**One list is the apply order and the allowlist both**, because a separate
allowlist is one more thing that can disagree with the order it is applied in.

The order is Room's rather than nostr's: every archivable kind has a foreign key
on the one before it, and kind order is not dependency order -- a dialect (30304)
has to land before an artifact (30300), and a translation chapter (30308) hangs
off a translation artifact version (30306) which hangs off an artifact version
(30301). So it is a list, not a `sortedBy { kind }`, and there is a test that
fails if anybody makes it one.

It is an allowlist first. 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. A `GroupKeyStateEvent` is
group-signed and passes verification perfectly, so an archive carrying an old one
is a validly signed statement about what the room signs with, replayed by whoever
kept a copy. Nothing but this list stops it. The contributor-list kinds (30305,
30307, 30310) are left out on the same principle from the other side:
`applyInnerEvent` has no arm that writes a row for any of them, so archiving them
would cost bytes and restore nothing.

**All-or-nothing parsing, per-payload verification.** These are not in tension;
they answer different questions. A page that will not parse has lost its framing,
and one silently shortened by an element would report a complete archive on its
page count while holding less than it says. A payload whose signature does not
verify is a well-framed page with one bad event in it, and costing its honest
neighbours would let a single forgery deny an entire archive.

**The count cap was 256 and 256 can never fire.** An event carries 64 characters
of id, 64 of pubkey and 128 of signature before it says anything, so the floor is
about 370 bytes and a 64 KB page cannot hold much past 170 of them -- the byte
cap always binds first and the count cap is a check that never runs. Found by
writing the test that a page at exactly the cap still decodes, which failed. Now
128, where both bind something: the count stops a page of many small payloads,
the bytes stop a page of few large ones. That test is what fails if somebody
later raises one number without the other, and the doc comment says they have to
move together.

**The `p` tag is a hint, not access control**, and `ArchiveRecipientTag` says so
where it is defined. The page is an ordinary group message and every member can
read it, which is right, because it is their own history going back to them. What
it decides is who *acts*: a device that is not named applies nothing, since 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.

`ArchivePageTag` refuses an index outside its own count rather than clamping it.
The pair is how a receiver decides it has everything, so a repaired one would let
a truncated archive read as complete.

Twenty-one tests over the codec, both caps, the allowlist, the order and the
tags. Also corrects the phase-2 section of the plan, which still said 256.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 14:00:30 +02:00
Kgothatso Ngako
3ee1676a04 docs: plan handing a new member the group's signed history
A member added after the work was done sees none of it, and nothing in the app
will ever show it to them. Two independent reasons, and the second is the one
that surprises people.

MLS gives no history: a Welcome carries the ratchet tree at the current epoch,
not the transcript, and `MarmotInboundManager` drops anything from an epoch it
holds no keys for. That is forward secrecy working rather than a gap to close.

But group-signed events never travel at all. `FrostSigningManager.complete` says
so in as many words -- a signed event authored by the threshold key cannot go out
as an inner event, because the outbound pipeline would re-author it as its sender
and strip the group's signature off -- so every device *derives* the finished
event from its own `FrostSigningItem` rows. A member who was not in the session
has no items, and no later message carries the event. So the second problem does
not follow from the first and is not fixed by fixing it: even a member who could
decrypt the whole back-transcript would still hold nothing an artifact, chapter
or chunk could be built from.

Which makes an archive not a convenience but the only path, and fixes the line
the design has to hold: **it carries what the group signed, never the chat.**
Restoring the chat would undo forward secrecy on purpose, and a signed event is
the only thing a new member can check for themselves.

**The property the whole plan rests on is already true.** A room's id *is* the
group's threshold key derived at the room's path -- `GroupKeyState.verifies` and
`FrostSigningManager.signingPath` hold that invariant from their own ends -- so
`isSignedByGroup`'s three checks collapse to `event.pubKey == chatRoomId`, an id
check and a signature verify. No key state row, no threshold key, no path, no
lookup. A member who can name the room can verify its signatures, which is
exactly the position a new member is in, and it means the sender of an archive
does not have to be trusted at all.

**Two guards the plan makes non-negotiable.** Nothing on the inbound nip30303
path verifies a signature today, and that is currently correct: rumors carry an
empty sig and are authenticated by the MLS frame, so nothing on the wire has ever
claimed group authorship. An archive is the first thing that does, so the verify
is the feature's entire security rather than hardening on top of it.

And verification turns "group-signed" into an admission ticket for the apply
path, which is a wider door than it looks: a `GroupKeyStateEvent` is group-signed
and would pass perfectly, so an archive could replay a genuine old one and
re-point what the room signs with. The archive therefore carries an allowlist of
document kinds, checked outbound and independently inbound -- the same shape, and
the same reasoning, as the cap on `k` in frost-batch-signing.md.

**Push and pull, in that order of appearance and the reverse order of
importance.** Pushing an archive after the Welcome is what the question asked
for, and on its own it fails the way marmot-membership.md describes: it is an
application message in the epoch the add created, so one that beats the Welcome
there is dropped rather than deferred, silently, while the inviter sees a
success. So the joiner asks instead -- a request is proof it has processed its
Welcome, and it covers the reinstall and the second device, which no invite-time
push can. The push stays as a latency optimisation, deliberately phased after the
thing that makes it safe.

Nine phases: the verifier, the events, assembling an archive, applying one and
the sweep that lets pages arrive out of order, the request, the push, UI, the
cross-device tests, and rollout. The sweep needs no new table -- the inbound path
already stores every inner event it decrypts, so it is the shape
`FrostSigningManager.replayStoredMessages` already has.

Also written down, because it is the first thing this will be reported as a bug
for: an archive lets a new member *read* everything and does not let them sign
anything. `proposeSigningBatch` wants a secret share and a place in the ceremony,
and a group that re-runs its ceremony derives a different room rather than
re-keying this one. Closing that needs share resharing, which is a great deal
more work than this and is the thing to build after it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 13:52:33 +02:00