Merge branch 'mantra' into claude/happy-gauss-dbe258

Brings in the Chronicle rename and the deprecation of the row rebuild, and
carries the supersession fix across into the new vocabulary.

Git followed every rename on its own -- `ArchiveManager` -> `ChronicleManager`,
the tests, the docs -- and auto-merged all three files my fix had touched. What
it could not do is rename identifiers inside the hunks it merged, so the fix
arrived speaking the old language: `ChronicleAssemblyJvmTest` still called
`ArchiveManager.assemble` and `ArchiveEvent.decodePage`, which does not compile,
and six doc comments in `ChronicleManager` and `GroupSignedEvent` still said
"archive" -- the exact ambiguity with archiving a chat that the rename exists to
remove.

One real conflict, in the design note, and it is the same sentence twice: my
correction of "a retranslated passage archives once" against the rename of the
uncorrected claim. Resolved to the correction, in the new vocabulary -- the
property still holds, it just stopped being free the moment the chronicle was
read from `GroupSignedEvent` rather than rebuilt from rows, and
`ChronicleManager.currentTranslationsOnly` is what holds it up.

`compileKotlinJvm` passes over a test file that does not compile, so it was no
evidence here; `compileTestKotlinJvm` is. And the filter was re-checked the way
it was written: removing it fails the same three tests, so the merge did not
quietly neuter them.

503 jvm tests and 297 android unit tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-06 16:37:12 +02:00
39 changed files with 6694 additions and 637 deletions

View File

@@ -10,7 +10,7 @@ silent, or a decision that looked arbitrary and was not.
| [shared-key-derivation.md](./shared-key-derivation.md) | deriving further keys from the group's threshold key with FROST tweaks — why not BIP32, why no chain code, and the one rule that must not be broken |
| [frost-batch-signing.md](./frost-batch-signing.md) | signing several events in one ceremony — why one nonce can never cover two messages, and the phased schema, wire and UI work that follows from it |
| [marmot-membership.md](./marmot-membership.md) | how members join an MLS group, and the epoch race that makes a missing member look like a successful invite |
| [member-archive.md](./member-archive.md) | handing a member added after the work was done the group's signed record — why the events are not on the wire at all, and why the room's id is enough to verify them |
| [member-chronicle.md](./member-chronicle.md) | handing a member added after the work was done the group's signed record — why the events are not on the wire at all, and why the room's id is enough to verify them |
| [marmot-direct-messages.md](./marmot-direct-messages.md) | a one-to-one message inside a group as a stock NIP-59 gift wrap — what its MIP-03 carve-out costs, why the sender cannot read their own, and the one query that would broadcast it |
| [mls-skipped-keys.md](./mls-skipped-keys.md) | why a group event that arrives a moment late is dropped for good, which flows trigger it, the quartz fix, and the partial mitigation in this app |
| [long-running-sync.md](./long-running-sync.md) | the chat subscriptions that stay open instead of pulling once per screen — why the request queue could not simply hold one, and how the group filter follows the room list |
@@ -23,7 +23,7 @@ report — it is silent, and it looks like every other kind of delivery failure.
sync note stands alone, and the dead-code inventory reads as a follow-up to it. The
batch-signing note is a phased plan that has been built: read it after the
derivation note, whose one rule is the same one it is built around. The member
archive note is a phased plan that has not been built, and reads as the
chronicle note is a phased plan that has not been built, and reads as the
membership note's unanswered half: what a member who joins late can be given,
and the one thing they cannot. The
jvm-target note is unrelated to all of them: it is a build and packaging story.

View File

@@ -2,7 +2,7 @@
A member added after the work was done sees none of it, and no amount of waiting
fixes that. This is how to send them the group's signed record, why the sending
member cannot be trusted and does not need to be, and the one thing an archive
member cannot be trusted and does not need to be, and the one thing a chronicle
cannot give them.
Read [shared-key-derivation.md](./shared-key-derivation.md) first. The property
@@ -17,7 +17,7 @@ times worth reading:
| what the plan said | what it turned out to be |
|---|---|
| nine archivable kinds | six at first, eight now. The three that were unsigned were fixed in the app rather than worked around here -- see [Phase 3](#what-is-actually-archivable) |
| nine chroniclable kinds | six at first, eight now. The three that were unsigned were fixed in the app rather than worked around here -- see [Phase 3](#what-is-actually-chroniclable) |
| `MAX_PAGE_EVENTS = 256` | 128. At 256 the byte cap always binds first and the count cap can never fire |
| "assemble, order, pack and queue" | assemble only; queueing moved to Phase 5, next to the thing that decides when |
| "re-read the room between the invite and the assembly" | unnecessary; that rule is about the MLS snapshot a commit is built on |
@@ -52,10 +52,10 @@ not follow from the first and is not fixed by fixing it: even a member who could
decrypt the entire 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
Which makes a chronicle not a convenience but the only path, and fixes the line
the design has to hold:
> **An archive carries what the group signed. Never the chat.**
> **A chronicle carries what the group signed. Never the chat.**
Two reasons, and the second is the load-bearing one. Restoring the chat would
undo forward secrecy on purpose. And a signed event is the only thing a new
@@ -93,17 +93,17 @@ what follows:
| question | answer, and why |
|---|---|
| Who may send an archive? | Anyone in the room. The receiver checks every payload, so a hostile sender can inject nothing. |
| Who may send a chronicle? | Anyone in the room. The receiver checks every payload, so a hostile sender can inject nothing. |
| Does it need encrypting to the recipient? | No. It is the group's own history going back to the group. |
| Does the new member need the key state first? | No. That was the ordering problem this removes. |
| What can a hostile archive do? | Omit. Not forge. See [What this does not do](#what-this-does-not-do). |
| What can a hostile chronicle do? | Omit. Not forge. See [What this does not do](#what-this-does-not-do). |
### The guard that is not optional
Nothing on the inbound nip30303 path verifies a signature today.
`ChatMessage.applyInnerEvent` parses and upserts, and that is *correct* as things
stand: rumors carry `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
on the wire has ever claimed group authorship. A chronicle is the first thing that
does.
So `isSignedByRoom` is not hardening. It is the feature's entire security, and
@@ -118,10 +118,10 @@ signature. That makes **every kind the group has ever signed replayable by any
member at any time**, which is a larger door than it first looks.
`GroupKeyStateEvent` (30326) is group-signed and would pass `isSignedByRoom`
perfectly. An archive carrying an old one is a validly signed statement about
perfectly. A chronicle carrying an old one is a validly signed statement about
which key the room signs with, replayed by whoever kept a copy.
> **An archive carries an allowlist of document kinds, never everything that
> **A chronicle carries an allowlist of document kinds, never everything that
> verifies.** The list is the nip30303 kinds `applyInnerEvent` dispatches, and
> the rule is checked on the way out *and independently on the way in*.
@@ -132,10 +132,10 @@ boundary.
## Push and pull
The obvious trigger is the invite: send the archive right after the Welcome. That
The obvious trigger is the invite: send the chronicle right after the Welcome. That
works, and on its own it is unreliable in the way
[marmot-membership.md](./marmot-membership.md#why-this-fails-silently) describes.
An archive is an application message in the epoch the add created. If it reaches
A chronicle is an application message in the epoch the add created. If it reaches
the invitee before their Welcome does -- different transports, no ordering
guarantee -- it is **dropped, not deferred**, and the sender sees nothing wrong.
@@ -144,9 +144,9 @@ The fix is not to make the push more careful. It is to let the joiner ask:
- A request is proof of arrival. A device that can send an application message in
the room has processed its Welcome; the race has nothing left to lose.
- It covers what a push never can. A reinstall, a second device, a member whose
inviter has since left, an archive that was lost -- none of those has an invite
inviter has since left, a chronicle that was lost -- none of those has an invite
to hang off.
- It converges. Requests repeat, archives are idempotent, and any member can
- It converges. Requests repeat, chronicles are idempotent, and any member can
answer.
So both, on the same two events: **the pull is the mechanism and the push is a
@@ -168,7 +168,7 @@ split the existing check in two and keep the existing one as a caller:
*
* The room's id is the group's signing key -- see shared-key-derivation.md --
* so this needs nothing but an id the caller already has. That is what makes
* an archive checkable by a member who holds no key state and no share.
* a chronicle checkable by a member who holds no key state and no share.
*/
fun isSignedByRoom(event: Event, chatRoomId: HexKey): Boolean = runCatching {
if (!event.pubKey.equals(chatRoomId, ignoreCase = true)) return false
@@ -197,12 +197,12 @@ is even reached.
**A day.**
A new package, `press.mantra.compose.nostr.archive`, with `ArchiveEvents.kt`
A new package, `press.mantra.compose.nostr.chronicle`, with `ChronicleEvents.kt`
holding the kinds -- mirroring `FrostSigningEvents`.
```
holder --[ 30327 archive ]-> one member a page of signed events
joiner --[ 30328 archive request ]-> everyone "I have none of this"
holder --[ 30327 chronicle ]-> one member a page of signed events
joiner --[ 30328 chronicle request ]-> everyone "I have none of this"
```
**Why 3032x and not 30313.** The nip30303 family runs 30300 to `SubmissionEvent`
@@ -213,7 +213,7 @@ rather than a decision, and the next family added should not rely on it." This i
that next family, so it does not. 30327 and 30328 sit past `GroupKeyStateEvent`
at 30326 and clash with nothing on either transport.
It is also the right neighbourhood on the merits. An archive is not a document
It is also the right neighbourhood on the merits. A chronicle is not a document
kind; it is a statement about the record, which is what `GroupKeyStateEvent` is
too.
@@ -224,11 +224,11 @@ id, author and signature", and its header even names the case. It is still the
wrong kind here, for three reasons:
- **A submission is an act** -- *this member is putting this event in front of
this group*. An archive asserts nothing; it re-delivers what the group already
this group*. A chronicle asserts nothing; it re-delivers what the group already
agreed. On one kind, a 400-event backfill is indistinguishable from 400 new
submissions, and every device has to guess which it is looking at.
- **N submissions are N inner events and N kind:445s.** A page is one.
- **The submission arm files a `ChatMessage` per payload.** An archive must not
- **The submission arm files a `ChatMessage` per payload.** A chronicle must not
-- see Phase 4.
### Shape
@@ -242,8 +242,8 @@ Tags, one value each, per the house convention:
| tag | holds | why |
|---|---|---|
| `ArchiveIdTag` | 32-byte hex | Ties pages of one archive together, so two members answering the same request do not interleave into one nonsense sequence. |
| `ArchivePageTag` | index, total | The receiver can say whether it holds a whole archive. |
| `ChronicleIdTag` | 32-byte hex | Ties pages of one chronicle together, so two members answering the same request do not interleave into one nonsense sequence. |
| `ChroniclePageTag` | index, total | The receiver can say whether it holds a whole chronicle. |
| `p` | recipient pubkey | **A hint, not access control** -- see Phase 4. |
### Two caps, both enforced on receive
@@ -268,7 +268,7 @@ exactly the cap still decodes, which is the assertion that fails when somebody
raises one number without the other.
Both are checked independently on the way in, for the reason the batch cap is:
an archive is the second place in this protocol where a remote party decides how
a chronicle is the second place in this protocol where a remote party decides how
much work everyone else does. Measure the 64 KB against a finished kind:445
rather than trusting it -- MLS framing and NIP-44 expansion both sit outside it.
@@ -277,11 +277,11 @@ array containing a non-event is refused whole.
---
## Phase 3 -- assembling an archive
## Phase 3 -- assembling a chronicle
**A day.**
`ArchiveManager.assemble(database, chatRoomId, recipient): List<EventTemplate<*>>`
`ChronicleManager.assemble(database, chatRoomId, recipient): List<EventTemplate<*>>`
Read every group-signed event this device holds for the room, order it, and pack
it into pages.
@@ -295,7 +295,7 @@ keeps the decision about transcript lines next to the decision about triggers.
> **Since the `GroupSignedEvent` table landed**, a signed event *is* stored as an
> event -- `FrostSigningManager` files one per batch it completes, and
> `ArchiveManager.applyPage` files one per payload it accepts, each with the
> `ChronicleManager.applyPage` files one per payload it accepts, each with the
> derivation path its author was reached at. `assemble` reads that table first
> and rebuilds only what it does not hold, which is work signed before the table
> existed. So the rest of this section describes the *fallback*: the round-trip
@@ -307,14 +307,14 @@ keeps the decision about transcript lines next to the decision about triggers.
> - **The allowlist now does real work on the way out.** The rebuild could only
> ever produce document kinds; the table holds everything the group has signed,
> and every room signs a `GroupKeyStateEvent` as its first act. `assemble`
> filters on `ArchiveEvent.isArchivable` before anything else -- without it
> `ArchiveEvent.build` refuses the page and a room's whole archive fails on the
> filters on `ChronicleEvent.isChroniclable` before anything else -- without it
> `ChronicleEvent.build` refuses the page and a room's whole chronicle fails on the
> one event every room has.
> - **An artifact whose initial version row is missing now archives.** The
> - **An artifact whose initial version row is missing now chronicles.** The
> rebuild has to recover the version label from that row and logs and gives up
> without it; on file as an event, the label never left.
Signed events are not stored as events; they are stored as rows. So the archive
Signed events are not stored as events; they are stored as rows. So the chronicle
is rebuilt from `Mantra*` rows via each entity's `toXEvent()`, which is exactly
what the round-trip convention exists for: `toXEvent` emits tags in the same
order as `build`, so the id round-trips, and the row carries `signature` and
@@ -322,7 +322,7 @@ order as `build`, so the id round-trips, and the row carries `signature` and
**This is the assumption to test first, before writing anything else in this
phase.** If any entity's `toXEvent` does not round-trip to an id whose signature
still verifies, that entity cannot be archived at all, and it is better to find
still verifies, that entity cannot be chronicled 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.
@@ -342,14 +342,14 @@ 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
### What is actually chroniclable
An archive can only carry what its receiver can check, so the list is exactly the
A chronicle can only carry what its receiver can check, so the list is exactly the
kinds a signing session produces. Eight of the thirteen nip30303 kinds do.
| kind | | why |
|---|---|---|
| 30304 Dialect, 30300 Artifact, 30301 ArtifactVersion, 30302 Chapter, 30303 Chunk, 30306 TranslationArtifactVersion, 30308 TranslationChapter, 30309 TranslationChunk | archivable | proposed through `proposeSigning` / `proposeSigningBatch` |
| 30304 Dialect, 30300 Artifact, 30301 ArtifactVersion, 30302 Chapter, 30303 Chunk, 30306 TranslationArtifactVersion, 30308 TranslationChapter, 30309 TranslationChunk | chroniclable | proposed through `proposeSigning` / `proposeSigningBatch` |
| 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 |
@@ -358,7 +358,7 @@ that mattered.** An artifact version was derived from the signed artifact on
arrival -- a row naming the group as its author with no signature to show for it
-- and a translated chunk was submitted as its author's rumor by
`MantraDao.saveTranslation`. Neither could be put in front of somebody with no
way to check it, so an archive restored everything a translation hangs on and not
way to check it, so a chronicle restored everything a translation hangs on and not
the translation.
Both were fixed in the app rather than worked around here, in parallel with this
@@ -373,9 +373,9 @@ end: 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 it
really is last.
**A retranslated passage archives once, and that stopped being free.** The arm
**A retranslated passage chronicles once, and that stopped being free.** The arm
that applies a translation chunk drops the row it supersedes -- newest by the
timestamp the group signed at, id breaking a tie -- so while the archive was
timestamp the group signed at, id breaking a tie -- so while the chronicle was
rebuilt from rows, a sender simply had nothing but the group's current answer to
each passage and that is what travelled.
@@ -383,12 +383,12 @@ Reading from `GroupSignedEvent` changed it. The record keeps every event the
group ever signed, deliberately: a signature is the group's statement and
discarding one is not that table's business. So a passage translated three times
leaves one row and three events, and an unfiltered read would put every draft a
group ever signed into every archive it ever sends, for as long as the room
group ever signed into every chronicle it ever sends, for as long as the room
exists.
`ArchiveManager.currentTranslationsOnly` is what holds the original property up.
It restates the applying arm's rule rather than approximating it -- newest by
signed timestamp, id breaking a tie -- because an archive that shipped one
`ChronicleManager.currentTranslationsOnly` is what holds the original property
up. It restates the applying arm's rule rather than approximating it -- newest by
signed timestamp, id breaking a tie -- because a chronicle that shipped one
translation as current while the recipient settled on another would have both
validly signed and nothing downstream to notice the disagreement. Dropping the
drafts is safe precisely *because* the recipient applies the same rule: it is not
@@ -397,7 +397,7 @@ on arrival.
### Ordering
Room enforces the shape, so an archive out of order is a foreign key violation
Room enforces the shape, so a chronicle out of order is a foreign key violation
rather than a wrong answer. The rank:
| # | kind | event | depends on |
@@ -424,15 +424,15 @@ kind 30309; they have identical dependencies, so one rank covers both, and
The same rule the batch signing work landed on -- *the thing being referenced is
signed first* -- and the same reason.
**Pages preserve the rank across the whole archive**, not within each page. Page
**Pages preserve the rank across the whole chronicle**, not within each page. Page
boundaries fall wherever the byte cap lands.
### Packing
Greedy: serialise, accumulate, cut when the next event would cross either cap.
An event that alone exceeds `MAX_PAGE_BYTES` cannot be archived; log it by id and
carry on rather than failing the archive. That is a real hole and should be
visible -- but a chapter nobody can archive is better than a member who gets
An event that alone exceeds `MAX_PAGE_BYTES` cannot be chronicled; log it by id and
carry on rather than failing the chronicle. That is a real hole and should be
visible -- but a chapter nobody can chronicle is better than a member who gets
nothing.
---
@@ -446,7 +446,7 @@ nothing.
A page names its recipient in a `p` tag, and **a device that is not the named
recipient stores the inner event and does nothing else.** It already holds the
work; re-applying would rewrite `marmotGroupEventId` on every one of its rows to
point at an archive page rather than at the event that actually introduced it,
point at a chronicle page rather than at the event that actually introduced it,
which is provenance loss for no gain.
So the `p` tag is an addressing hint and not a secret. Say so where it is
@@ -456,7 +456,7 @@ history. What the tag decides is who *acts*.
### Applying
```kotlin
ArchiveManager.apply(database, chatRoomId, page: MarmotInnerEvent)
ChronicleManager.apply(database, chatRoomId, page: MarmotInnerEvent)
```
1. Parse the content array. A page that will not parse is dropped whole.
@@ -474,8 +474,8 @@ transaction and one bad event should not take the room down with it.
**Discard the chat lines.** `ChatMessage` has an `autoGenerate` primary key, so
every applied payload mints a *new* row -- there is no id to dedupe on. An
archive that filed them would give the new member a synthetic transcript dated
now, and give them a second one on every re-run of the sweep. The archive
chronicle that filed them would give the new member a synthetic transcript dated
now, and give them a second one on every re-run of the sweep. The chronicle
restores the work; the conversation is forward secret and stays gone.
`applyInnerEvent` already does its entity upserts internally and merely *returns*
@@ -495,12 +495,12 @@ shape:
```kotlin
database.marmotInnerEventDao()
.getByChatRoomAndKinds(chatRoomId, listOf(ArchiveEvents.ARCHIVE))
.getByChatRoomAndKinds(chatRoomId, listOf(ChronicleEvents.CHRONICLE))
```
Re-apply every stored page for the room, oldest first, after each new page
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.
free and a converged chronicle 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
@@ -512,12 +512,12 @@ last one learned something; a pass that does not is as far as these pages get.
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.
out-of-order chronicle 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.
**Test:** an archive delivered in reverse page order converges to the same rows
**Test:** a chronicle delivered in reverse page order converges to the same rows
as one delivered in order; a page whose payloads are all already applied changes
nothing; a page containing one forged payload applies the rest.
@@ -527,7 +527,7 @@ nothing; a page containing one forged payload applies the rest.
**A day, including one schema change.**
`ArchiveRequestEvent` (30328), sent into the room, content empty.
`ChronicleRequestEvent` (30328), sent into the room, content empty.
**When a device sends one.** On entering a room it holds no signed work for --
no `MantraArtifact` and no `MantraDialect` rows -- having processed its Welcome.
@@ -537,7 +537,7 @@ because all three look identical from inside the database, which is the point.
**Who answers.** Any member holding the work. Answering costs bandwidth and
nothing else -- pages are idempotent and non-recipients skip them -- so a
duplicate answer is waste, not damage. A random 0-30 s stand-down, skipped if
another member's archive for that request id is already on the wire, is worth
another member's chronicle for that request id is already on the wire, is worth
adding and is worth adding *last*: it is an optimisation, and shipping it with
the correctness would make it look like part of it.
@@ -546,7 +546,7 @@ the correctness would make it look like part of it.
One nullable column, so Room generates it:
```kotlin
val archiveRequestedAt: Instant? = null // on ChatRoom
val chronicleRequestedAt: Instant? = null // on ChatRoom
AutoMigration(from = 11, to = 12)
```
@@ -555,7 +555,7 @@ Rooms written before it read back null, meaning "never asked" -- true of all of
them, and harmless: the request is only sent for a room with no work in it, and
a room that has work will not ask.
Clear it when an archive for the room applies anything, so a partial answer is
Clear it when a chronicle for the room applies anything, so a partial answer is
followed by another request rather than by silence.
---
@@ -569,7 +569,7 @@ written as one.
`MarmotOutboundDao.deliveryWelcome` is the seam -- both branches of
`inviteMember` reach it, the immediate one and the ack-triggered one in
`DatabaseNostrRepository`. Assemble an archive for the invitee there and queue
`DatabaseNostrRepository`. Assemble a chronicle for the invitee there and queue
its pages behind the Welcome.
One thing to be honest about at that call site, in a comment: **queued behind the
@@ -591,7 +591,7 @@ land is not an error; it is the ordinary case the pull exists for. It sits insid
**One call, two occasions.** Answering a request and pushing behind a Welcome are
the same operation and differ only in who decided, so they are one function named
for what it does -- `ArchiveManager.sendTo` -- rather than two named for their
for what it does -- `ChronicleManager.sendTo` -- rather than two named for their
occasions.
---
@@ -600,22 +600,22 @@ occasions.
**A day.**
**The transcript gets one line per archive**, not one per event. Three types --
`TYPE_ARCHIVE_REQUESTED`, `TYPE_ARCHIVE_SENT`, `TYPE_ARCHIVE_RECEIVED` -- in
`ARCHIVE_TYPES`, with an arm in the transcript that renders them as notices. A
**The transcript gets one line per chronicle**, not one per event. Three types --
`TYPE_CHRONICLE_REQUESTED`, `TYPE_CHRONICLE_SENT`, `TYPE_CHRONICLE_RECEIVED` -- in
`CHRONICLE_TYPES`, with an arm in the transcript that renders them as notices. A
type missing from that set renders as a chat bubble, silently, looking exactly
like a member having said *"Caught up on 12 items"*.
Three decisions inside that:
- **The received line is written when the request stamp is cleared**, which is as
close to one-per-archive as this can get: an archive's pages are not
close to one-per-chronicle as this can get: a chronicle'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.
- **A push behind a Welcome writes no line at all**, 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.
- **The received line names no sender.** An archive can be assembled from pages
- **The received line names no sender.** A chronicle can be assembled from pages
sent by more than one member, so attributing the catch-up to one of them would
be a guess dressed as a fact.
@@ -632,7 +632,7 @@ left out rather than written blind:
and it wants a screen to live on.
**Say what the new member cannot do.** Still unwritten, and now down to one
thing rather than two: an archive hands its recipient the group's whole signed
thing rather than two: a chronicle hands its recipient the group's whole signed
record, prose included, and does not make them able to *sign* anything. That is
the sentence a member wants the first time they open a room they were added to
late, and the first thing this will be reported as a bug for.
@@ -648,21 +648,21 @@ batch-signing note's did, so this section is the index rather than the work.
Every claim below is asserted somewhere; what is here is which claim and where.
**The whole thing, end to end** --
[ArchiveApplyJvmTest](../composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ArchiveApplyJvmTest.kt),
[ChronicleApplyJvmTest](../composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleApplyJvmTest.kt),
over two real databases with the pages carried by hand. The sender's room is
seeded through `ChatMessage.applyInnerEvent` itself, so what is archived is what
seeded through `ChatMessage.applyInnerEvent` itself, so what is chronicled is what
a member's device really holds rather than rows built to suit the test. The
receiver holds no share, no `DkgSession`, no `FrostSigningSession` and no
`GroupKeyState`, and ends with the sender's rows.
Compared as `(id, author, signature)` per row rather than by count, and then
asserted that every archived row is authored by the room and carries a signature.
asserted that every chronicled row is authored by the room and carries a signature.
Counting is not the claim: two databases can hold the same number of artifacts
and disagree about all of them, 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 this is for. The artifact version is the one exception and has to be:
nobody signs it, it is derived from the signed artifact on arrival, which is why
it is not archived and why a chapter's foreign key survives anyway.
it is not chronicled and why a chapter's foreign key survives anyway.
**The negative one that matters** -- four ways to be a dishonest member in one
page beside one honest dialect: the room's id as author with a made-up signature,
@@ -675,17 +675,17 @@ this does.
**The replay that must not work** -- a genuine, still-verifying
`GroupKeyStateEvent` in a hand-rolled page. It passes every signature check there
is; the allowlist is the only thing that stops it, and the page has to be
hand-rolled because `ArchiveEvent.build` refuses the kind, which is the outbound
hand-rolled because `ChronicleEvent.build` refuses the kind, which is the outbound
half of the same rule.
**The `toXEvent` round trip per archived kind** --
[ArchiveRoundTripTest](../composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/archive/ArchiveRoundTripTest.kt),
**The `toXEvent` round trip per chronicled kind** --
[ChronicleRoundTripTest](../composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/chronicle/ChronicleRoundTripTest.kt),
against real FROST with no database. This is the one that earned its place: it
found that `MantraArtifact.toArtifactEvent` had the alt tag in the wrong position
*and* omitted the version metadata entirely, either of which produces payloads
every receiver silently drops as forgeries. It also holds the negative -- a
rebuild with the wrong version label fails as a forgery rather than as a mistake
-- and a guard that the case list and `ARCHIVABLE_KINDS` move together.
-- and a guard that the case list and `CHRONICLABLE_KINDS` move together.
**And three that were not in the first draft**, each written because a test
passed for the wrong reason or a bound could not fire:
@@ -694,9 +694,9 @@ passed for the wrong reason or a bound could not fire:
page-over-the-cap test passes while the count cap is unreachable behind the
byte cap, which is how it was first written.
- *Pages delivered backwards really did fail first.* Otherwise "out of order
converges" would pass on an archive that was never out of order, and the sweep
converges" would pass on a chronicle that was never out of order, and the sweep
-- the only reason it converges -- would be untested.
- *The page that completes an archive leaves nothing behind.* This is what caught
- *The page that completes a chronicle leaves nothing behind.* This is what caught
the sweep returning the sum of its passes rather than the settled one, which
made `failed > 0` stop meaning "still missing".
@@ -707,18 +707,18 @@ passed for the wrong reason or a bound could not fire:
**No code, and one constraint that is sharper than the first draft said.**
The receiving half is safe to ship on its own, and phases 1-4 are exactly that:
nothing sends an archive until Phase 5 asks for one. That is the half to have in
nothing sends a chronicle until Phase 5 asks for one. That is the half to have in
the field first.
**Sending into a group with an old build is not free.** The first draft said an
old build "files it as unsupported, exactly as it does today for anything it does
not know", which is true and reads better than it lives. The 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
every member on an old build sees each chronicle page as a raw-JSON bubble of up to
`MAX_PAGE_BYTES`, in a transcript, 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
unpleasant transcript, and that is worth knowing before the first chronicle goes
out rather than after. The rule:
> Confirm every member is on a build that understands kind 30327 before any
@@ -730,7 +730,103 @@ The mitigation, if that ever proves unacceptable, is the one the appendix reject
for other reasons: carrying pages as Marmot direct messages, where an old build
sees a gift wrap it cannot open and renders *"sent a private message"* with no
content. It buys graceful degradation and costs everything listed under
[Carrying the archive as a Marmot direct message](#appendix--what-was-considered-and-rejected).
[Carrying the chronicle as a Marmot direct message](#appendix--what-was-considered-and-rejected).
---
## Retiring the rebuild
`assemble` reads `GroupSignedEvent` and rebuilds from `Mantra*` rows only what
that table does not hold, which by now is work signed before the table existed.
Everything on the rebuild side is marked `@Deprecated` so the compiler names it
at every call site, and it comes out in one piece rather than a method at a time
-- a half-removed rebuild is a rebuild that covers some kinds and silently drops
others.
### The one precondition
**No install still holds work signed before schema v13.** Nothing in the app can
check this, and no query answers it: a device that upgraded is indistinguishable
from one that never had pre-v13 work, and the rows that need rebuilding are on
*other people's* devices. It is a judgement about the installed base, not a
condition to test for. What can be checked, on any given device, is that the
rebuild is contributing nothing:
```
Chronicling <room>: N event(s) as the group signed them, M rebuilt from rows that predate the record
```
`ChronicleManager.signedEventsOf` logs that line only when `M > 0`. Silence across
the fleet is the evidence; it is not proof.
A member whose device still needs it and does not get it is not broken loudly.
They keep their own rows and read the room normally. What they lose is the
ability to *answer* a chronicle request with the older half of the group's work,
so a newer member asks, is answered, and receives a chronicle that is quietly
short. That is the failure mode to weigh -- it looks like success on both ends.
### What goes
| what | where |
|---|---|
| `rebuiltEventsOf` | [ChronicleManager.kt](../composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChronicleManager.kt) -- the tree walk, and the version-label recovery inside it |
| the union in `signedEventsOf` | same file -- it collapses to the `GroupSignedEvent` read plus the `isChroniclable` filter, which **stays**: see below |
| `MantraDialect.toDialectEvent` | [MantraDialect.kt](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraDialect.kt) |
| `MantraArtifact.toArtifactEvent` | [MantraArtifact.kt](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifact.kt) -- and with it the `versionLabel` parameter that exists only because the label is not on the row |
| `MantraArtifactVersion.toArtifactVersionEvent` | [MantraArtifactVersion.kt](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifactVersion.kt) |
| `MantraChapter.toChapterEvent` | [MantraChapter.kt](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraChapter.kt) |
| `MantraChunk.toChunkEvent` | [MantraChunk.kt](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraChunk.kt) |
| `MantraTranslationArtifactVersion.toTranslationArtifactVersionEvent` | [MantraTranslationArtifactVersion.kt](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraTranslationArtifactVersion.kt) |
| `MantraTranslationChapter.toTranslationChapterEvent` | [MantraTranslationChapter.kt](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraTranslationChapter.kt) |
| `MantraTranslationChunk.toTranslationChunkEvent` | [MantraTranslationChunk.kt](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraTranslationChunk.kt) |
| `ChronicleRoundTripTest`, all ten cases | [ChronicleRoundTripTest.kt](../composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/chronicle/ChronicleRoundTripTest.kt) -- it exists to hold the rebuild up and covers nothing else |
| the "Where the events come from" reasoning above | this file |
**Two already-dead cousins to sweep at the same time**, neither of which is part
of the rebuild and both of which will look like it to whoever does the removal:
`MantraTranslation.toTranslationEvent` (nothing has ever called it -- 30311 is
not chroniclable and nothing builds one) and
`MantraTranslationChunkProposal.toTranslationChunkEvent` (on a model that is not
even a `@Database` entity). See [dead-code.md](./dead-code.md) for the house
style on writing those down rather than deleting them blind.
**The tests that seed without recording go too**, or they go on proving a path
that no longer exists. In `ChronicleAssemblyJvmTest` the `apply`-only seeding is
the rebuild path and `recordEverythingApplied` is the real one; the cases named
*work held both ways travels exactly once* and *an artifact the rebuild has to
leave out still chronicles from the record* are about the union specifically and
have no meaning without it. `ChronicleApplyJvmTest` seeds the sender the same way,
so it needs the recording call added rather than removed -- it is testing
delivery, not assembly, and would otherwise start asserting against an empty
chronicle.
### What only looks like it goes
**The `isChroniclable` filter in `signedEventsOf` stays, and becomes the only
thing standing.** It is not part of the rebuild; it is there *because* of the
record. The rebuild could only ever produce document kinds, so nothing needed
filtering while it was the source. The table holds every kind the group has
signed, and every room signs a `GroupKeyStateEvent` as its first act -- so
removing that filter along with the walk turns every room's chronicle into an
`IllegalArgumentException` from `ChronicleEvent.build`. Two cases in
`ChronicleAssemblyJvmTest` fail with exactly that if it is dropped, which is the
guard against removing it by association.
**The verify filter in `assemble` stays.** With the rebuild gone it is checking
events that were verified before they were recorded, so it can never fail in
practice -- which is an argument for keeping it, not for dropping it. It is one
signature check standing between a corrupted row and a payload every receiver
reads as a forgery, and "cannot happen" is the state it is meant to preserve.
**`Mantra*.signature` and `Mantra*.publicKey` are not obviously removable, and
are a separate decision.** They were what made a row rebuildable, but they are
also what `SignedArtifactTest`, `SignedChapterTest`, `SignedGroupKeyStateTest`
and `ChronicleApplyJvmTest.rowFingerprints` assert on, and
`MantraTranslationContributor` builds a contributor list out of one. Since v13,
`groupSignedEventId` says whether the group signed a row and points at the proof,
so the columns are arguably redundant -- but that is a schema migration across
twelve tables with its own tests to rewrite, and it should not ride along with
this.
---
@@ -738,7 +834,7 @@ content. It buys graceful degradation and costs everything listed under
Each of these will be reported as a bug. None of them is.
**A new member still cannot sign, and an archive cannot change that.** This is
**A new member still cannot sign, and a chronicle cannot change that.** This is
the big one. `proposeSigningBatch` resolves a `DkgSession` with a non-null
`secretShare` and then `signerIdOf`, or throws *"This device is not a participant
in ceremony ..."*. `GroupKeyState` states it plainly: *"A member can be in the
@@ -747,7 +843,7 @@ reinstalled -- and the state is still worth keeping: it says what the room signs
with, which is what tells them they cannot."*
Re-running the ceremony is not an escape either: *"a group that re-runs its
ceremony derives a different room rather than re-keying this one."* A post-archive
ceremony derives a different room rather than re-keying this one."* A post-chronicle
member can read everything and can still submit what needs no quorum --
`saveTranslation` and `addArtifactVersion` go through `MantraDao.submitToGroup`
with no share -- but cannot add a dialect, artifact, chapter or translation
@@ -758,7 +854,7 @@ share to a new participant without changing the public key it derives from.
It is a real protocol, it is a great deal more work than this document, and it is
the thing to build after this one.
**An archive can omit.** Verification stops forgery and does nothing about
**A chronicle can omit.** Verification stops forgery and does nothing about
silence: a sender can leave things out, and the receiver has no way to know. Any
member can send one and they merge idempotently, so asking a second member is the
practical answer, and a group that suspects one member is not the threat model
@@ -768,35 +864,35 @@ 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.
**Nothing unsigned is archived, and that is the whole list.** For a while it read
**Nothing unsigned is chronicled, and that is the whole list.** For a while it read
larger: the translated text was its author's rumor and an artifact's first
version was derived rather than signed, so neither could travel and a new member
got the structure and none of the prose. Both are signed now. What is left out is
`TranslationEvent`, which nothing builds, and the contributor lists, which
nothing applies -- so the rule and the list have stopped diverging, and the thing
to watch is that they do not drift apart again. The guard is
`ArchiveRoundTripTest`, which fails when a kind is added to the allowlist without
`ChronicleRoundTripTest`, which fails when a kind is added to the allowlist without
a case proving it can be rebuilt.
**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
first thing a new member will notice and the chronicle is what makes them expect
otherwise.
**A room with no shared key gets an empty archive.** An ordinary Marmot room's id
**A room with no shared key gets an empty chronicle.** An ordinary Marmot room's id
is `RandomInstance.bytes(32)`, not a derived key, so nothing can be signed by it
and there is nothing to archive. Correct, and worth a log line rather than a
and there is nothing to chronicle. Correct, and worth a log line rather than a
silent empty result.
**An oversized single event cannot be archived.** A chapter whose text exceeds
**An oversized single event cannot be chronicled.** A chapter whose text exceeds
`MAX_PAGE_BYTES` on its own is skipped with a log. Splitting a page mid-event
means a reassembly protocol, and that is not worth building before something hits
the limit.
**Nothing expires.** An archive grows with the group forever, and a member
**Nothing expires.** A chronicle grows with the group forever, and a member
joining a five-year-old room downloads five years. A cursor -- *everything since
event X* -- is the obvious next thing and is deliberately not in v1, because
"since" is a partial order over a dependency graph, not a timestamp, and getting
it wrong means an archive that references rows the receiver does not have.
it wrong means a chronicle that references rows the receiver does not have.
---
@@ -817,10 +913,10 @@ id is in the `h` tag of every kind:445 the group has ever sent. A separate,
deliberate publication step for work a group *chooses* to publish is a good
feature; making it the backfill mechanism is a leak.
**One `SubmissionEvent` per archived event.** Covered in Phase 2. The envelope
**One `SubmissionEvent` per chronicled event.** Covered in Phase 2. The envelope
fits and the meaning does not.
**Carrying the archive as a Marmot direct message.** The natural reading of "send
**Carrying the chronicle as a Marmot direct message.** The natural reading of "send
it to the new member" -- an NIP-59 wrap inside the group, per
[marmot-direct-messages.md](./marmot-direct-messages.md). Rejected: it encrypts
the group's own history to one member, which protects nothing; it costs a *"sent
@@ -829,7 +925,7 @@ are not forward secret, so it would be the weakest-protected copy of the group's
record on any device holding it. The `p` tag as a hint gets the addressing
without any of that.
**A dedicated table for unapplied archive payloads.** Phase 4's sweep reads
**A dedicated table for unapplied chronicle payloads.** Phase 4's sweep reads
`MarmotInnerEvent`, which already holds every page. A second copy is a second
thing that can disagree with the first.