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:
Kgothatso NgakoandClaude Opus 5 committed 2026-09-06 16:27:36 +02:00
1 parent a69d80d38f
commit ea11e8b233
33 files changed
+6506 -606

No files matched your search

@@ -520,7 +520,7 @@ class GroupKeyStateTest {
// `isSignedByGroup` is the caller that knows a threshold key and a path.
// `isSignedByRoom` is the same three checks with the walk already done, and
// it is what a member holding neither has to work from -- see
// docs/member-archive.md. What these pin down is that a room id alone is
// docs/member-chronicle.md. What these pin down is that a room id alone is
// enough, and that it is enough for any kind rather than only a key state.
/** An event of [kind] authored and signed by [author]'s room at [path]. */
@@ -575,11 +575,11 @@ class GroupKeyStateTest {
@Test
fun `the verifier does not care what kind it is looking at`() {
// The point of splitting it out: an archive carries documents rather than
// The point of splitting it out: a chronicle carries documents rather than
// key states, and none of the three checks knows the difference.
//
// Note the last one. A GroupKeyStateEvent signed by the room passes here
// exactly as a dialect does, which is why the archive needs an allowlist
// exactly as a dialect does, which is why the chronicle needs an allowlist
// of kinds on top of this and cannot treat "the group signed it" as
// permission to apply it.
listOf(
@@ -629,7 +629,7 @@ class GroupKeyStateTest {
@Test
fun `claiming the room as author proves nothing without the signature`() {
// The forgery an archive would otherwise carry: write the room's id into
// The forgery a chronicle would otherwise carry: write the room's id into
// pubKey -- every member knows it, it is in the h tag of every kind:445
// the group has ever sent -- and put anything at all in sig. The author
// check and the id check both pass. The signature is the whole feature.
@@ -677,7 +677,7 @@ class GroupKeyStateTest {
@Test
fun `malformed input is a no rather than a throw`() {
// Everything here arrives off the wire inside somebody else's archive, so
// Everything here arrives off the wire inside somebody else's chronicle, so
// the failure mode has to be a verdict. A throw would take down the whole
// inbound transaction the page is being applied in.
val signed = signedByRoom()
@@ -1,4 +1,4 @@
package press.mantra.compose.nostr.archive
package press.mantra.compose.nostr.chronicle
import com.vitorpamplona.quartz.nip01Core.core.Event
import kotlin.test.Test
@@ -8,9 +8,9 @@ import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import press.mantra.compose.nostr.archive.tags.ArchiveIdTag
import press.mantra.compose.nostr.archive.tags.ArchivePageTag
import press.mantra.compose.nostr.archive.tags.ArchiveRecipientTag
import press.mantra.compose.nostr.chronicle.tags.ChronicleIdTag
import press.mantra.compose.nostr.chronicle.tags.ChroniclePageTag
import press.mantra.compose.nostr.chronicle.tags.ChronicleRecipientTag
import press.mantra.compose.nostr.frost.FrostSigningEvents
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
import press.mantra.compose.nostr.nip30303.ArtifactEvent
@@ -28,16 +28,16 @@ import press.mantra.compose.nostr.nip30303.TranslationContributorListEvent
import press.mantra.compose.nostr.nip30303.TranslationEvent
/**
* The envelope an archive travels in: what it carries, what it refuses, and the
* The envelope a chronicle travels in: what it carries, what it refuses, and the
* order it puts things in.
*
* Nothing here verifies a signature -- that is `GroupKeyStateTest`'s half, and
* `ArchiveManager`'s at apply time. What these cover is the framing, and the two
* `ChronicleManager`'s at apply time. What these cover is the framing, and the two
* bounds a page has to hold whoever sent it.
*/
class ArchiveEventTest {
class ChronicleEventTest {
private val recipient = "d".repeat(64)
private val archiveId = "e".repeat(64)
private val chronicleId = "e".repeat(64)
private fun payload(
kind: Int,
@@ -62,7 +62,7 @@ class ArchiveEventTest {
payload(ArtifactEvent.KIND, "an artifact"),
)
val decoded = ArchiveEvent.decodePage(ArchiveEvent.encodePage(events))
val decoded = ChronicleEvent.decodePage(ChronicleEvent.encodePage(events))
assertNotNull(decoded)
assertEquals(events.map { it.content }, decoded.map { it.content })
@@ -78,26 +78,26 @@ class ArchiveEventTest {
fun `a page of one is still an array`() {
// The contrast with FrostSigningEvents.encodeProposal, which keeps a bare
// object for a batch of one so that builds predating batching can read
// it. Both archive kinds are new, so there is no such build and no reason
// it. Both chronicle kinds are new, so there is no such build and no reason
// to carry the second shape.
val encoded = ArchiveEvent.encodePage(listOf(payload(DialectEvent.KIND)))
val encoded = ChronicleEvent.encodePage(listOf(payload(DialectEvent.KIND)))
assertTrue(encoded.startsWith("["), "a page is always an array")
assertTrue(encoded.endsWith("]"))
assertEquals(1, ArchiveEvent.decodePage(encoded)?.size)
assertEquals(1, ChronicleEvent.decodePage(encoded)?.size)
}
@Test
fun `a page that is not an array is not a page`() {
assertNull(ArchiveEvent.decodePage(payload(DialectEvent.KIND).toJson()))
assertNull(ArchiveEvent.decodePage(""))
assertNull(ArchiveEvent.decodePage("not json"))
assertNull(ArchiveEvent.decodePage("[unclosed"))
assertNull(ChronicleEvent.decodePage(payload(DialectEvent.KIND).toJson()))
assertNull(ChronicleEvent.decodePage(""))
assertNull(ChronicleEvent.decodePage("not json"))
assertNull(ChronicleEvent.decodePage("[unclosed"))
}
@Test
fun `an empty page is refused rather than read as an empty archive`() {
assertNull(ArchiveEvent.decodePage("[]"))
fun `an empty page is refused rather than read as an empty chronicle`() {
assertNull(ChronicleEvent.decodePage("[]"))
}
@Test
@@ -105,29 +105,29 @@ class ArchiveEventTest {
// All-or-nothing here, and per-payload at verify time. They answer
// different questions: a page that will not parse has lost its framing,
// so a page quietly shortened by one element would report a complete
// archive on its page count while holding less than it says. A payload
// chronicle 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 dropping its honest neighbours would let one forgery
// deny an entire archive.
// deny an entire chronicle.
val good = payload(DialectEvent.KIND)
assertNull(ArchiveEvent.decodePage("[${good.toJson()},\"not an event\"]"))
assertNull(ArchiveEvent.decodePage("[\"not an event\",${good.toJson()}]"))
assertNull(ChronicleEvent.decodePage("[${good.toJson()},\"not an event\"]"))
assertNull(ChronicleEvent.decodePage("[\"not an event\",${good.toJson()}]"))
}
// ---- The two caps ----------------------------------------------------
@Test
fun `a page over the event cap is refused on the way in`() {
val overCap = (0..ArchiveEvent.MAX_PAGE_EVENTS).map {
val overCap = (0..ChronicleEvent.MAX_PAGE_EVENTS).map {
payload(DialectEvent.KIND, id = it.toString().padStart(64, '0'))
}
assertEquals(ArchiveEvent.MAX_PAGE_EVENTS + 1, overCap.size)
assertEquals(ChronicleEvent.MAX_PAGE_EVENTS + 1, overCap.size)
assertNull(ArchiveEvent.decodePage(ArchiveEvent.encodePage(overCap)))
assertNull(ChronicleEvent.decodePage(ChronicleEvent.encodePage(overCap)))
// And exactly at the cap is fine, so the bound is not off by one.
assertNotNull(ArchiveEvent.decodePage(ArchiveEvent.encodePage(overCap.dropLast(1))))
assertNotNull(ChronicleEvent.decodePage(ChronicleEvent.encodePage(overCap.dropLast(1))))
}
@Test
@@ -137,30 +137,30 @@ class ArchiveEventTest {
// count cap set above what MAX_PAGE_BYTES can hold is a check that never
// runs, and the page-over-the-cap test above passes for the wrong reason.
// Both caps have to be re-sized together; this is what says so.
val atCap = (0 until ArchiveEvent.MAX_PAGE_EVENTS).map {
val atCap = (0 until ChronicleEvent.MAX_PAGE_EVENTS).map {
payload(DialectEvent.KIND, id = it.toString().padStart(64, '0'))
}
val page = ArchiveEvent.encodePage(atCap)
val page = ChronicleEvent.encodePage(atCap)
assertTrue(
page.encodeToByteArray().size <= ArchiveEvent.MAX_PAGE_BYTES,
page.encodeToByteArray().size <= ChronicleEvent.MAX_PAGE_BYTES,
"MAX_PAGE_EVENTS payloads take ${page.encodeToByteArray().size} bytes, " +
"over the ${ArchiveEvent.MAX_PAGE_BYTES}-byte cap: the event cap can never fire"
"over the ${ChronicleEvent.MAX_PAGE_BYTES}-byte cap: the event cap can never fire"
)
assertEquals(ArchiveEvent.MAX_PAGE_EVENTS, ArchiveEvent.decodePage(page)?.size)
assertEquals(ChronicleEvent.MAX_PAGE_EVENTS, ChronicleEvent.decodePage(page)?.size)
}
@Test
fun `a page over the byte cap is refused on the way in`() {
// Checked before the JSON is parsed, because the cap exists to bound the
// work a remote party can ask this device to do. An archive is the second
// work a remote party can ask this device to do. A chronicle is the second
// place in this protocol where somebody else sets that size.
val huge = ArchiveEvent.encodePage(
listOf(payload(DialectEvent.KIND, content = "x".repeat(ArchiveEvent.MAX_PAGE_BYTES)))
val huge = ChronicleEvent.encodePage(
listOf(payload(DialectEvent.KIND, content = "x".repeat(ChronicleEvent.MAX_PAGE_BYTES)))
)
assertTrue(huge.encodeToByteArray().size > ArchiveEvent.MAX_PAGE_BYTES)
assertTrue(huge.encodeToByteArray().size > ChronicleEvent.MAX_PAGE_BYTES)
assertNull(ArchiveEvent.decodePage(huge))
assertNull(ChronicleEvent.decodePage(huge))
}
@Test
@@ -168,15 +168,15 @@ class ArchiveEventTest {
// A multi-byte payload that fits as characters and does not as bytes. If
// this were measured in characters the cap would let through up to four
// times what it says.
val wide = "世".repeat(ArchiveEvent.MAX_PAGE_BYTES / 2)
val page = ArchiveEvent.encodePage(listOf(payload(DialectEvent.KIND, content = wide)))
val wide = "世".repeat(ChronicleEvent.MAX_PAGE_BYTES / 2)
val page = ChronicleEvent.encodePage(listOf(payload(DialectEvent.KIND, content = wide)))
assertTrue(page.length < ArchiveEvent.MAX_PAGE_BYTES)
assertTrue(page.encodeToByteArray().size > ArchiveEvent.MAX_PAGE_BYTES)
assertNull(ArchiveEvent.decodePage(page))
assertTrue(page.length < ChronicleEvent.MAX_PAGE_BYTES)
assertTrue(page.encodeToByteArray().size > ChronicleEvent.MAX_PAGE_BYTES)
assertNull(ChronicleEvent.decodePage(page))
}
// ---- What an archive may carry ---------------------------------------
// ---- What a chronicle may carry ---------------------------------------
@Test
fun `the allowlist is every kind the group signs, and only those`() {
@@ -189,72 +189,72 @@ class ArchiveEventTest {
TranslationArtifactVersionEvent.KIND,
TranslationChapterEvent.KIND,
TranslationChunkEvent.KIND,
).forEach { assertTrue(ArchiveEvent.isArchivable(it), "kind $it should be archivable") }
).forEach { assertTrue(ChronicleEvent.isChroniclable(it), "kind $it should be chroniclable") }
assertEquals(8, ArchiveEvent.ARCHIVABLE_KINDS.size)
assertEquals(8, ChronicleEvent.CHRONICLABLE_KINDS.size)
}
@Test
fun `the translated text is archivable, now that the group signs it`() {
fun `the translated text is chroniclable, now that the group signs it`() {
// Worth its own case because it was the feature's headline limitation for
// as long as `saveTranslation` wrote the row and queued the member's own
// rumor. An archive can only carry what the receiver can check, so a
// rumor. A chronicle can only carry what the receiver can check, so a
// translation nobody signed could not travel, and a new member got the
// whole structure and none of the prose.
assertTrue(ArchiveEvent.isArchivable(TranslationChunkEvent.KIND))
assertTrue(ChronicleEvent.isChroniclable(TranslationChunkEvent.KIND))
// Same shape, same fix: the version an artifact starts life with used to
// be derived on arrival, so it was authored by the group with no
// signature to show for it. It is the second item of the artifact's batch
// now.
assertTrue(ArchiveEvent.isArchivable(ArtifactVersionEvent.KIND))
assertTrue(ChronicleEvent.isChroniclable(ArtifactVersionEvent.KIND))
}
@Test
fun `the kinds nobody signs are still left out`() {
// Nothing builds one of these at all; the inbound arm exists and no
// producer does.
assertFalse(ArchiveEvent.isArchivable(TranslationEvent.KIND))
assertFalse(ChronicleEvent.isChroniclable(TranslationEvent.KIND))
}
@Test
fun `an archive may not carry a key state, however well signed`() {
fun `a chronicle may not carry a key state, however well signed`() {
// The attack the allowlist exists for, and the reason it is an allowlist
// rather than a denylist. A GroupKeyStateEvent is signed by the room and
// passes verification perfectly, so an archive carrying an old one is a
// passes verification perfectly, so a chronicle carrying an old one is a
// validly signed statement about what the room signs with, replayed by
// whoever kept a copy. Nothing but this stops it.
assertFalse(ArchiveEvent.isArchivable(GroupKeyStateEvent.KIND))
assertNull(ArchiveEvent.applyRank(GroupKeyStateEvent.KIND))
assertFalse(ChronicleEvent.isChroniclable(GroupKeyStateEvent.KIND))
assertNull(ChronicleEvent.applyRank(GroupKeyStateEvent.KIND))
}
@Test
fun `an archive carries documents, not envelopes or protocol`() {
assertFalse(ArchiveEvent.isArchivable(SubmissionEvent.KIND))
assertFalse(ArchiveEvent.isArchivable(ArchiveEvent.KIND))
assertFalse(ArchiveEvent.isArchivable(ArchiveRequestEvent.KIND))
fun `a chronicle carries documents, not envelopes or protocol`() {
assertFalse(ChronicleEvent.isChroniclable(SubmissionEvent.KIND))
assertFalse(ChronicleEvent.isChroniclable(ChronicleEvent.KIND))
assertFalse(ChronicleEvent.isChroniclable(ChronicleRequestEvent.KIND))
FrostSigningEvents.ALL.forEach {
assertFalse(ArchiveEvent.isArchivable(it), "frost kind $it")
assertFalse(ChronicleEvent.isChroniclable(it), "frost kind $it")
}
}
@Test
fun `the contributor lists are left out while nothing applies them`() {
// Not an oversight. ChatMessage.applyInnerEvent has no arm that writes a
// row for any of these, so archiving them would cost bytes and restore
// row for any of these, so chronicling them would cost bytes and restore
// nothing. They belong in the list on the day that changes.
assertFalse(ArchiveEvent.isArchivable(TranslationArtifactVersionContributorListEvent.KIND))
assertFalse(ArchiveEvent.isArchivable(TranslationChapterContributorListEvent.KIND))
assertFalse(ArchiveEvent.isArchivable(TranslationContributorListEvent.KIND))
assertFalse(ChronicleEvent.isChroniclable(TranslationArtifactVersionContributorListEvent.KIND))
assertFalse(ChronicleEvent.isChroniclable(TranslationChapterContributorListEvent.KIND))
assertFalse(ChronicleEvent.isChroniclable(TranslationContributorListEvent.KIND))
}
// ---- Dependency order ------------------------------------------------
@Test
fun `the order is the foreign keys, not the kind numbers`() {
fun rank(kind: Int) = assertNotNull(ArchiveEvent.applyRank(kind), "kind $kind has no rank")
fun rank(kind: Int) = assertNotNull(ChronicleEvent.applyRank(kind), "kind $kind has no rank")
// Every one of these is a foreign key in Room, so an archive applied the
// Every one of these is a foreign key in Room, so a chronicle applied the
// other way round is a constraint violation rather than a wrong answer.
assertTrue(rank(DialectEvent.KIND) < rank(ArtifactEvent.KIND))
assertTrue(rank(ArtifactEvent.KIND) < rank(ArtifactVersionEvent.KIND))
@@ -300,7 +300,7 @@ class ArchiveEventTest {
TranslationChapterEvent.KIND,
TranslationChunkEvent.KIND,
),
ArchiveEvent.inApplyOrder(jumbled).map { it.kind }
ChronicleEvent.inApplyOrder(jumbled).map { it.kind }
)
}
@@ -308,7 +308,7 @@ class ArchiveEventTest {
fun `two of a kind keep the order they were handed in`() {
// Nothing orders two chapters of one version, and keeping the caller's
// order is one less thing that differs between two members assembling the
// same archive.
// same chronicle.
val chapters = listOf(
payload(ChapterEvent.KIND, "one", id = "1".repeat(64)),
payload(ChapterEvent.KIND, "two", id = "2".repeat(64)),
@@ -317,24 +317,24 @@ class ArchiveEventTest {
assertEquals(
listOf("one", "two", "three"),
ArchiveEvent.inApplyOrder(chapters).map { it.content }
ChronicleEvent.inApplyOrder(chapters).map { it.content }
)
}
// ---- The tags --------------------------------------------------------
@Test
fun `a page says which archive it is, where in it, and who for`() {
val template = ArchiveEvent.build(
fun `a page says which chronicle it is, where in it, and who for`() {
val template = ChronicleEvent.build(
payloads = listOf(payload(DialectEvent.KIND)),
archiveId = archiveId,
chronicleId = chronicleId,
index = 2,
count = 5,
recipient = recipient,
createdAt = 1_700_000_100L
)
val readBack = ArchiveEvent(
val readBack = ChronicleEvent(
id = "f".repeat(64),
pubKey = "b".repeat(64),
createdAt = template.createdAt,
@@ -343,7 +343,7 @@ class ArchiveEventTest {
sig = ""
)
assertEquals(archiveId, readBack.archiveId())
assertEquals(chronicleId, readBack.chronicleId())
assertEquals(2, readBack.page()?.index)
assertEquals(5, readBack.page()?.count)
assertEquals(recipient, readBack.recipient())
@@ -351,31 +351,31 @@ class ArchiveEventTest {
}
@Test
fun `a page outside its own archive is not a page`() {
fun `a page outside its own chronicle is not a page`() {
// Refused rather than clamped: the pair is how a receiver decides it has
// everything, so a repaired one would let a truncated archive read as
// everything, so a repaired one would let a truncated chronicle read as
// complete.
assertNull(ArchivePageTag.parse(arrayOf(ArchivePageTag.TAG_NAME, "5", "3")))
assertNull(ArchivePageTag.parse(arrayOf(ArchivePageTag.TAG_NAME, "-1", "3")))
assertNull(ArchivePageTag.parse(arrayOf(ArchivePageTag.TAG_NAME, "0", "0")))
assertNull(ArchivePageTag.parse(arrayOf(ArchivePageTag.TAG_NAME, "0")))
assertNull(ArchivePageTag.parse(arrayOf(ArchivePageTag.TAG_NAME, "one", "3")))
assertNull(ChroniclePageTag.parse(arrayOf(ChroniclePageTag.TAG_NAME, "5", "3")))
assertNull(ChroniclePageTag.parse(arrayOf(ChroniclePageTag.TAG_NAME, "-1", "3")))
assertNull(ChroniclePageTag.parse(arrayOf(ChroniclePageTag.TAG_NAME, "0", "0")))
assertNull(ChroniclePageTag.parse(arrayOf(ChroniclePageTag.TAG_NAME, "0")))
assertNull(ChroniclePageTag.parse(arrayOf(ChroniclePageTag.TAG_NAME, "one", "3")))
assertEquals(0, ArchivePageTag.parse(arrayOf(ArchivePageTag.TAG_NAME, "0", "1"))?.index)
assertTrue(ArchivePageTag.parse(arrayOf(ArchivePageTag.TAG_NAME, "0", "1"))!!.isOnlyPage())
assertEquals(0, ChroniclePageTag.parse(arrayOf(ChroniclePageTag.TAG_NAME, "0", "1"))?.index)
assertTrue(ChroniclePageTag.parse(arrayOf(ChroniclePageTag.TAG_NAME, "0", "1"))!!.isOnlyPage())
}
@Test
fun `an archive id and a recipient have to be there to be read`() {
assertNull(ArchiveIdTag.parse(arrayOf(ArchiveIdTag.TAG_NAME)))
assertNull(ArchiveIdTag.parse(arrayOf(ArchiveIdTag.TAG_NAME, " ")))
assertNull(ArchiveIdTag.parse(arrayOf("other", archiveId)))
assertEquals(archiveId, ArchiveIdTag.parse(arrayOf(ArchiveIdTag.TAG_NAME, archiveId))?.archiveId)
fun `a chronicle id and a recipient have to be there to be read`() {
assertNull(ChronicleIdTag.parse(arrayOf(ChronicleIdTag.TAG_NAME)))
assertNull(ChronicleIdTag.parse(arrayOf(ChronicleIdTag.TAG_NAME, " ")))
assertNull(ChronicleIdTag.parse(arrayOf("other", chronicleId)))
assertEquals(chronicleId, ChronicleIdTag.parse(arrayOf(ChronicleIdTag.TAG_NAME, chronicleId))?.chronicleId)
assertNull(ArchiveRecipientTag.parse(arrayOf(ArchiveRecipientTag.TAG_NAME, "short")))
assertNull(ChronicleRecipientTag.parse(arrayOf(ChronicleRecipientTag.TAG_NAME, "short")))
assertEquals(
recipient,
ArchiveRecipientTag.parse(arrayOf(ArchiveRecipientTag.TAG_NAME, recipient))?.pubKey
ChronicleRecipientTag.parse(arrayOf(ChronicleRecipientTag.TAG_NAME, recipient))?.pubKey
)
}
@@ -387,34 +387,34 @@ class ArchiveEventTest {
// security -- the inbound one is the boundary -- but a page nobody can
// read back is better failed where it was made.
assertFailsWith<IllegalArgumentException> {
ArchiveEvent.build(emptyList(), archiveId, 0, 1, recipient)
ChronicleEvent.build(emptyList(), chronicleId, 0, 1, recipient)
}
assertFailsWith<IllegalArgumentException> {
ArchiveEvent.build(
payloads = (0..ArchiveEvent.MAX_PAGE_EVENTS).map {
ChronicleEvent.build(
payloads = (0..ChronicleEvent.MAX_PAGE_EVENTS).map {
payload(DialectEvent.KIND, id = it.toString().padStart(64, '0'))
},
archiveId = archiveId,
chronicleId = chronicleId,
index = 0,
count = 1,
recipient = recipient
)
}
assertFailsWith<IllegalArgumentException> {
ArchiveEvent.build(
ChronicleEvent.build(
payloads = listOf(payload(GroupKeyStateEvent.KIND)),
archiveId = archiveId,
chronicleId = chronicleId,
index = 0,
count = 1,
recipient = recipient
)
}
assertFailsWith<IllegalArgumentException> {
ArchiveEvent.build(
ChronicleEvent.build(
payloads = listOf(
payload(DialectEvent.KIND, content = "x".repeat(ArchiveEvent.MAX_PAGE_BYTES))
payload(DialectEvent.KIND, content = "x".repeat(ChronicleEvent.MAX_PAGE_BYTES))
),
archiveId = archiveId,
chronicleId = chronicleId,
index = 0,
count = 1,
recipient = recipient
@@ -424,9 +424,9 @@ class ArchiveEventTest {
@Test
fun `a request carries nothing but what it is`() {
val template = ArchiveRequestEvent.build(createdAt = 1_700_000_100L)
val template = ChronicleRequestEvent.build(createdAt = 1_700_000_100L)
assertEquals(ArchiveRequestEvent.KIND, template.kind)
assertEquals(ChronicleRequestEvent.KIND, template.kind)
assertEquals("", template.content)
// The room is the envelope and the asker is the MLS sender, so there is
// nothing left for the event itself to say.
@@ -1,4 +1,4 @@
package press.mantra.compose.nostr.archive
package press.mantra.compose.nostr.chronicle
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
@@ -39,24 +39,24 @@ import press.mantra.compose.nostr.nip30303.TranslationChapterEvent
import press.mantra.compose.nostr.nip30303.TranslationChunkEvent
/**
* The assumption the whole archive rests on: a row can be turned back into the
* The assumption the whole chronicle rests on: a row can be turned back into the
* event the group signed.
*
* Signed events are not stored as events. `FrostSigningManager.complete` applies
* them and what survives is a `Mantra*` row, so an archive has to rebuild each
* them and what survives is a `Mantra*` row, so a chronicle has to rebuild each
* one with `toXEvent()` and hope it comes out byte-identical. If it does not,
* the id changes, the signature no longer covers it, and every receiver drops
* the payload as a forgery -- silently, one kind at a time.
*
* That is what the round-trip note on each `toXEvent` claims and what nothing
* asserted until now. Every kind in `ArchiveEvent.ARCHIVABLE_KINDS` is checked
* asserted until now. Every kind in `ChronicleEvent.CHRONICLABLE_KINDS` is checked
* here against a real FROST quorum, because a signature that verifies is the
* only evidence that the rebuild is faithful -- comparing fields would only
* prove the test and the code agree about which fields matter.
*
* A kind that fails here cannot be archived at all, whatever the allowlist says.
* A kind that fails here cannot be chronicled at all, whatever the allowlist says.
*/
class ArchiveRoundTripTest {
class ChronicleRoundTripTest {
private val participants = 3
private val threshold = 2
@@ -267,7 +267,7 @@ class ArchiveRoundTripTest {
@Test
fun `an artifact version survives the trip through a row`() {
// The second fault of the same shape, found the moment this kind became
// archivable: `toArtifactVersionEvent` emitted [artifactId, alt] where
// chroniclable: `toArtifactVersionEvent` emitted [artifactId, alt] where
// `build` emits [alt, artifactId]. It had been that way for as long as
// nothing called it, which is what makes an unused rebuild dangerous
// rather than merely dead -- the claim in its comment was never checked.
@@ -300,7 +300,7 @@ class ArchiveRoundTripTest {
@Test
fun `a translated chunk survives the trip through a row`() {
// The translated text. Archivable only because the app stopped saving it
// The translated text. Chroniclable only because the app stopped saving it
// as its author's rumor and started asking the group to sign it -- until
// then a new member got the whole structure and none of the prose.
assertRoundTrips(
@@ -401,14 +401,14 @@ class ArchiveRoundTripTest {
}
@Test
fun `every archivable kind has a round-trip test above`() {
fun `every chroniclable kind has a round-trip test above`() {
// The list and this file have to move together. A kind added to the
// allowlist without a case here is one nobody has checked can be rebuilt,
// and the failure is silent: the receiver drops it as a forgery.
assertEquals(
8,
ArchiveEvent.ARCHIVABLE_KINDS.size,
"an archivable kind was added or removed; add or remove its round-trip case"
ChronicleEvent.CHRONICLABLE_KINDS.size,
"a chroniclable kind was added or removed; add or remove its round-trip case"
)
}
}