refactor: call it a chronicle, and keep "archive" for what a user does to a chat
Archiving a chat is an ordinary thing a user will want to do to a conversation, and it is not this. This is the group's signed record, handed to a member who joined after the work was done so their room stops being empty. Two unrelated meanings of one word in one app is a bug waiting to be written, and `ChatRoom.archiveRequestedAt` is exactly where they would have met: a column on the chat row, named for the thing that is not the chat. So the whole feature is Chronicle now -- `press.mantra.compose.nostr.chronicle`, `ChronicleEvent` (30327), `ChronicleRequestEvent` (30328), the three tags, `ChronicleManager`, `docs/member-chronicle.md`. The kind numbers do not move; only the words do. **The wire tags move too**, `archiveId` -> `chronicleId` and `archivePage` -> `chroniclePage`, which is free exactly once. Both kinds are new and there is no old build to stay compatible with -- the design note says so in as many words -- so the alternative was carrying the old spelling on the wire forever to save a rename that costs nothing today. The recipient tag stays `p`; it was never ours. **Schema v14, because two things had the old word written into stored data.** `ChatRoom.archiveRequestedAt` becomes `chronicleRequestedAt`, renamed rather than dropped and re-added: while it is set it is the only record that a device with an empty room has already asked the group for its history, and a device that lost it mid-flight would ask again on its next launch, and the one after that. The three `ChatMessage.messageType` strings become their `chronicle*` spellings, rewritten rather than left to a legacy constant the way `dkgApprovalNeeded` was. These lines cannot be regenerated -- a chronicle is announced once, when it is requested, sent and applied -- and an unrecognised type is not skipped by the transcript. It renders as an ordinary chat bubble, so "Caught up on 12 items" would come back attributed to a member as something they said. `MIGRATION_13_14` does both, because Room can rename a column and cannot rewrite rows in the same breath. `ALTER TABLE ... RENAME COLUMN` needs SQLite 3.25, which `getRoomDatabase` guarantees by pinning `BundledSQLiteDriver`, and the column is in no index, no foreign key, and there is not a view or trigger in the database -- so nothing has to move with it. Five tests hold the two halves apart: the value survives, the column keeps its position, a room that never asked still reads as never having asked, the three types are rewritten, and every other type is left alone. **`isArchivable` is `isChroniclable`**, on the "recyclable" pattern, and it keeps its job unchanged: the allowlist that stands between a replayed `GroupKeyStateEvent` and the apply path. No behaviour change beyond the rename. 797 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,14 +373,14 @@ 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.** The arm that applies a translation
|
||||
**A retranslated passage chronicles 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 a sender holds a group's current answer to each passage
|
||||
rather than its drafts, and that is what travels.
|
||||
|
||||
### 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 |
|
||||
@@ -407,15 +407,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.
|
||||
|
||||
---
|
||||
@@ -429,7 +429,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
|
||||
@@ -439,7 +439,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.
|
||||
@@ -457,8 +457,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*
|
||||
@@ -478,12 +478,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
|
||||
@@ -495,12 +495,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.
|
||||
|
||||
@@ -510,7 +510,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.
|
||||
@@ -520,7 +520,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.
|
||||
|
||||
@@ -529,7 +529,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)
|
||||
```
|
||||
|
||||
@@ -538,7 +538,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.
|
||||
|
||||
---
|
||||
@@ -552,7 +552,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
|
||||
@@ -574,7 +574,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.
|
||||
|
||||
---
|
||||
@@ -583,22 +583,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.
|
||||
|
||||
@@ -615,7 +615,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.
|
||||
@@ -631,21 +631,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,
|
||||
@@ -658,17 +658,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:
|
||||
@@ -677,9 +677,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".
|
||||
|
||||
@@ -690,18 +690,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
|
||||
@@ -713,7 +713,7 @@ 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).
|
||||
|
||||
---
|
||||
|
||||
@@ -721,7 +721,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
|
||||
@@ -730,7 +730,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
|
||||
@@ -741,7 +741,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
|
||||
@@ -751,35 +751,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.
|
||||
|
||||
---
|
||||
|
||||
@@ -800,10 +800,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
|
||||
@@ -812,7 +812,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.
|
||||
|
||||
Reference in New Issue
Block a user