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

@@ -177,7 +177,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
UnsignedNostrEvent::class,
Zap::class
],
version = 13,
version = 14,
autoMigrations = [
// v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can
// generate the migration itself — nothing existing changes shape.
@@ -232,12 +232,13 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
// a room that could only propose one thing at a time cannot have written
// two sessions' lines into the same stretch of transcript.
AutoMigration(from = 10, to = 11),
// v12 adds the nullable ChatRoom.archiveRequestedAt, which stops a device
// with no signed work in a room asking the group for its history on every
// launch while an answer is in flight. Rooms written before it read back
// null, meaning "never asked" -- true of all of them, and harmless: the
// request only goes out for a room that holds no signed work, and a room
// that holds some will not ask.
// v12 adds the nullable ChatRoom.archiveRequestedAt -- renamed to
// chronicleRequestedAt in v14 -- which stops a device with no signed work
// in a room asking the group for its history on every launch while an
// answer is in flight. Rooms written before it read back null, meaning
// "never asked" -- true of all of them, and harmless: the request only
// goes out for a room that holds no signed work, and a room that holds
// some will not ask.
AutoMigration(from = 11, to = 12),
// v13 adds the GroupSignedEvent table and the nullable
// `groupSignedEventId` on every row a signed event turns into. A new
@@ -246,7 +247,14 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
// hold the event behind this row" -- true of all of them, since nothing
// kept it. They are not backfilled: the events are gone, and inventing
// an id for one would point a row at a signature nobody can produce.
AutoMigration(from = 12, to = 13)
AutoMigration(from = 12, to = 13),
// v14 changes one column name and three stored strings, all of which say
// "archive" about the group's signed record. The word was wanted back for
// the ordinary thing a user does to a chat, and a column called
// `archiveRequestedAt` sitting on ChatRoom is exactly where the two
// meanings would have met. Room can rename a column and cannot rewrite the
// rows in the same breath, so this is a manual migration passed to the
// builder rather than an entry here. See MIGRATION_13_14.
]
)
@ColumnTypeConverters(MantraConverters::class)

View File

@@ -4,6 +4,7 @@ import androidx.room3.RoomDatabase
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import press.mantra.compose.database.migrations.MIGRATION_3_4
import press.mantra.compose.database.migrations.MIGRATION_9_10
import press.mantra.compose.database.migrations.MIGRATION_13_14
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
@@ -17,11 +18,12 @@ fun getRoomDatabase(
builder: RoomDatabase.Builder<press.mantra.compose.database.MantraDatabase>
): press.mantra.compose.database.MantraDatabase {
return builder
// Everything else Room generates itself. These two move data rather than
// Everything else Room generates itself. These three move data rather than
// only changing shape, which an AutoMigration cannot express: 3->4 rewrites
// chat rows, and 9->10 copies a session's per-event columns onto the items
// table before dropping them.
.addMigrations(MIGRATION_3_4, MIGRATION_9_10)
// chat rows, 9->10 copies a session's per-event columns onto the items
// table before dropping them, and 13->14 renames a column and rewrites the
// chat rows that named it the old way.
.addMigrations(MIGRATION_3_4, MIGRATION_9_10, MIGRATION_13_14)
.setDriver(BundledSQLiteDriver())
.setQueryCoroutineContext(Dispatchers.IO)
.build()

View File

@@ -42,9 +42,9 @@ abstract class GroupSignedEventDao {
* Files a signed event, keeping what is already known about it.
*
* The same event reaches a device more than once by design: it is applied
* when the session that made it completes, and again from any archive page
* when the session that made it completes, and again from any chronicle page
* carrying it. A plain upsert would let the second arrival overwrite the
* first, and the second arrival is the poorer one -- an archive knows no
* first, and the second arrival is the poorer one -- a chronicle knows no
* session and, on a member who joined after the ceremony, no derivation
* path. So the incoming row fills gaps and never empties them.
*

View File

@@ -3,7 +3,7 @@ package press.mantra.compose.database.dao
import androidx.room3.Dao
import androidx.room3.Transaction
import press.mantra.compose.database.MantraDatabase
import press.mantra.compose.managers.ArchiveManager
import press.mantra.compose.managers.ChronicleManager
import press.mantra.compose.database.model.BroadcastNostrEventRequest
import press.mantra.compose.database.model.ChatMessage
import press.mantra.compose.database.model.ChatMessageBroadcastNostrEventRequestRelation
@@ -527,7 +527,7 @@ abstract class MarmotOutboundDao(
// Nothing else will ever show it to them: MLS gives a joiner no
// history, and a group-signed event never travels -- every device
// derives it from a session it took part in, or does without. See
// docs/member-archive.md.
// docs/member-chronicle.md.
//
// **Queued behind the Welcome is not delivered after it.** These
// are different transports -- a relay-borne gift wrap and a
@@ -536,14 +536,14 @@ abstract class MarmotOutboundDao(
// an epoch ahead of theirs and is dropped outright rather than
// deferred. So this is a latency optimisation and not the
// mechanism: what recovers it is the invitee asking for
// themselves once they are in, which `ArchiveManager.requestIfEmpty`
// themselves once they are in, which `ChronicleManager.requestIfEmpty`
// does on their first open of the room.
//
// A push that does not land is therefore the ordinary case rather
// than an error, and nothing here reports one to the inviter. It
// is inside this function's catch for that reason, alongside the
// Welcome it rides behind.
ArchiveManager.sendTo(
ChronicleManager.sendTo(
database = database,
chatRoomId = nostrGroupId,
userPublicKey = userPublicKey,

View File

@@ -0,0 +1,60 @@
package press.mantra.compose.database.migrations
import androidx.room3.migration.Migration
import androidx.sqlite.SQLiteConnection
import androidx.sqlite.execSQL
import press.mantra.compose.database.model.ChatMessage
/**
* Renames the group's signed record from "archive" to "chronicle", everywhere the
* old word had been written down.
*
* The word was needed back. Archiving a chat is an ordinary thing a user does to
* a conversation, and the group's signed record is not that -- it is what a
* member who joined after the work was done is handed 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 precisely where they would have
* met: a column on the chat row, named for the thing that is not the chat.
*
* Two changes, one shape and one data, which is why this is a manual migration
* rather than a `@RenameColumn` spec Room could generate:
*
* - `ChatRoom.archiveRequestedAt` becomes `chronicleRequestedAt`. Renamed rather
* than dropped and re-added, because the value is load-bearing while it is
* set: it is the only thing stopping a device with an empty room asking the
* group for its history on every launch. A device that dropped it mid-flight
* would ask again on the next start, and again after that.
* - The three `ChatMessage.messageType` strings become their `chronicle*`
* spellings. Rewritten rather than left to a legacy constant, because these
* lines cannot be regenerated -- a chronicle is announced once, when it is
* requested, sent or applied -- and an unrecognised type does not render as
* nothing. It renders as a chat bubble, so "Caught up on 12 items" would come
* back attributed to a member as something they said.
*
* Matched on the exact old strings rather than a `LIKE`, because this app wrote
* all three of them one version ago and knows what they were. Anything else in
* the column is left alone.
*
* `ALTER TABLE ... RENAME COLUMN` needs SQLite 3.25, which `getRoomDatabase`
* guarantees by pinning `BundledSQLiteDriver` on every platform. The column is in
* no index and no foreign key -- see the v13 schema -- so nothing else in the
* table has to move with it.
*/
val MIGRATION_13_14 = object : Migration(13, 14) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL(
"ALTER TABLE `ChatRoom` RENAME COLUMN `archiveRequestedAt` TO `chronicleRequestedAt`"
)
mapOf(
"archiveRequested" to ChatMessage.TYPE_CHRONICLE_REQUESTED,
"archiveSent" to ChatMessage.TYPE_CHRONICLE_SENT,
"archiveReceived" to ChatMessage.TYPE_CHRONICLE_RECEIVED,
).forEach { (oldType, newType) ->
connection.execSQL(
"UPDATE `ChatMessage` SET `messageType` = '$newType' " +
"WHERE `messageType` = '$oldType'"
)
}
}
}

View File

@@ -10,7 +10,7 @@ import press.mantra.compose.database.model.traits.SoftDeletableEntity
import press.mantra.compose.database.model.traits.TimestampedEntity
import press.mantra.compose.database.model.traits.UserViewableEntity
import press.mantra.compose.exceptions.MarmotUnprocessableInnerEventException
import press.mantra.compose.managers.ArchiveManager
import press.mantra.compose.managers.ChronicleManager
import press.mantra.compose.managers.GroupKeyStateManager
import press.mantra.compose.extensions.toHex
import com.vitorpamplona.quartz.marmot.GroupEventResult
@@ -26,8 +26,8 @@ import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.ChapterEvent
import press.mantra.compose.nostr.nip30303.ChunkEvent
import press.mantra.compose.nostr.nip30303.DialectEvent
import press.mantra.compose.nostr.archive.ArchiveEvent
import press.mantra.compose.nostr.archive.ArchiveRequestEvent
import press.mantra.compose.nostr.chronicle.ChronicleEvent
import press.mantra.compose.nostr.chronicle.ChronicleRequestEvent
import press.mantra.compose.nostr.nip30303.SubmissionEvent
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionContributorListEvent
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
@@ -325,11 +325,11 @@ data class ChatMessage(
/**
* Handing a member the group's signed record, as lines in the chat.
*
* One line per archive rather than per page. A member who joins a working
* One line per chronicle rather than per page. A member who joins a working
* group has a room that fills itself in without explanation, and the
* alternative to saying so once is either silence -- which looks like a
* group that has done nothing -- or a line per applied payload, which is
* the transcript the archive deliberately does not write.
* the transcript the chronicle deliberately does not write.
*
* Content is self-contained: these are not predicates for a name to be
* read in front of, so they stay out of the AUTHORED sets. A recipient's
@@ -337,24 +337,24 @@ data class ChatMessage(
* which does not follow a rename and is the accepted cost for a line
* about a thing that happened once.
*
* The received line names no sender on purpose. An archive can be
* The received line names no sender on purpose. A chronicle can be
* assembled out of pages from more than one member, so attributing the
* catch-up to one of them would be a guess dressed as a fact.
*/
const val TYPE_ARCHIVE_REQUESTED = "archiveRequested"
const val TYPE_ARCHIVE_SENT = "archiveSent"
const val TYPE_ARCHIVE_RECEIVED = "archiveReceived"
const val TYPE_CHRONICLE_REQUESTED = "chronicleRequested"
const val TYPE_CHRONICLE_SENT = "chronicleSent"
const val TYPE_CHRONICLE_RECEIVED = "chronicleReceived"
/**
* Every archive line, for the one check the transcript dispatches on.
* Every chronicle line, for the one check the transcript dispatches on.
*
* A type missing from here renders as a chat bubble -- silently, and
* looking exactly like a member having said "Caught up on 12 items".
*/
val ARCHIVE_TYPES = setOf(
TYPE_ARCHIVE_REQUESTED,
TYPE_ARCHIVE_SENT,
TYPE_ARCHIVE_RECEIVED,
val CHRONICLE_TYPES = setOf(
TYPE_CHRONICLE_REQUESTED,
TYPE_CHRONICLE_SENT,
TYPE_CHRONICLE_RECEIVED,
)
const val TYPE_UNDECRYPTABLE_OUTER_LAYER = "undecryptableOuterLayer"
@@ -651,14 +651,14 @@ data class ChatMessage(
)
}
// An archive is neither a document nor a submission: it is a
// A chronicle is neither a document nor a submission: it is a
// bundle of documents addressed to one member who is missing
// them. Intercepted here rather than in applyInnerEvent for
// the same reason the gift wrap above is -- deciding whether
// to act needs the active key, which applyInnerEvent has no
// business knowing.
if (event.kind == ArchiveEvent.KIND) {
ArchiveManager.receive(
if (event.kind == ChronicleEvent.KIND) {
ChronicleManager.receive(
database = database,
chatRoomId = groupEventResult.groupId,
userPublicKey = activeKeyPair.pubKey.toHex(),
@@ -666,8 +666,8 @@ data class ChatMessage(
)
// No chat line, and not for want of one worth writing.
// One line per archive is right; one per page is not, and
// the pages of an archive are not distinguishable from
// One line per chronicle is right; one per page is not, and
// the pages of a chronicle are not distinguishable from
// each other here. That is Phase 7's, and until then a
// silent catch-up beats a transcript full of envelopes.
return null
@@ -679,8 +679,8 @@ data class ChatMessage(
// pages are idempotent and everyone but the recipient ignores
// them. A member with nothing signed answers nothing, which
// is the honest reply from one still catching up themselves.
if (event.kind == ArchiveRequestEvent.KIND) {
ArchiveManager.sendTo(
if (event.kind == ChronicleRequestEvent.KIND) {
ChronicleManager.sendTo(
database = database,
chatRoomId = groupEventResult.groupId,
userPublicKey = activeKeyPair.pubKey.toHex(),
@@ -818,7 +818,7 @@ data class ChatMessage(
* [groupSignedEventId] is the third way in, and the one with no
* envelope at all: a `GroupSignedEvent` the group put its own signature
* to, applied by `FrostSigningManager` when a session completes or by
* `ArchiveManager` when a page arrives. Both Marmot ids are null for
* `ChronicleManager` when a page arrives. Both Marmot ids are null for
* those -- there is no group event and no inner event behind them --
* which is why they need an id of their own, and why every row this
* writes carries it.

View File

@@ -83,16 +83,16 @@ data class ChatRoom(
* When this device last asked the group for its signed history, or null if
* it never has or the answer has since arrived.
*
* A device with no signed work in a room asks for an archive -- see
* `ArchiveManager.requestIfEmpty` and docs/member-archive.md. This is the
* A device with no signed work in a room asks for a chronicle -- see
* `ChronicleManager.requestIfEmpty` and docs/member-chronicle.md. This is the
* only thing stopping it asking again on every launch while an answer is in
* flight, and it is cleared as soon as an archive applies anything, so a
* flight, and it is cleared as soon as a chronicle applies anything, so a
* partial answer is followed by another request rather than by silence.
*
* Not a claim that anybody replied. Nothing acknowledges a request, and the
* room having work in it is the only evidence that ever arrives.
*/
val archiveRequestedAt: Instant? = null,
val chronicleRequestedAt: Instant? = null,
override val createdAt: Instant = Clock.System.now(),
override val updatedAt: Instant = createdAt,

View File

@@ -32,7 +32,7 @@ import press.mantra.compose.nostr.frost.GroupKeyStateEvent
* as itself -- the outbound pipeline re-authors rumors as their sender and would
* strip the signature off -- so it is not a `NostrEvent`, and it is not a
* `MarmotInnerEvent` either, since no member sent it. Every device derives it
* from a signing session it took part in, or is handed it in an archive. The
* from a signing session it took part in, or is handed it in a chronicle. The
* columns are `NostrEvent`'s so that what is stored is an event rather than a
* summary of one, which is what makes [verifies] answerable from the row alone.
*
@@ -96,9 +96,9 @@ data class GroupSignedEvent(
* The session that produced the signature, when this device ran it.
*
* Not a foreign key, and null on a device that was handed the event rather
* than signing it -- an archive recipient holds the group's work without
* than signing it -- a chronicle recipient holds the group's work without
* ever having held a session for it, which is the whole point of
* `docs/member-archive.md`. A session is also allowed to be swept away
* `docs/member-chronicle.md`. A session is also allowed to be swept away
* without taking the group's signed work with it.
*/
val frostSigningSessionId: String? = null,
@@ -139,7 +139,7 @@ data class GroupSignedEvent(
* documents -- signs as the bare threshold key rather than as its own id, so
* a perfectly good event in such a room fails here and cannot be made to
* pass: the key it would have to be checked against is not on the row and
* cannot be walked back to from one. Those rooms get an empty archive for
* cannot be walked back to from one. Those rooms get an empty chronicle for
* the same reason, which is a limit of the derivation rather than of this
* check.
*/
@@ -150,7 +150,7 @@ data class GroupSignedEvent(
* The row for a signed [event], as the group made it.
*
* Not verified here. Both callers have already checked the signature --
* `FrostSigningManager` before it applies a batch, `ArchiveManager`
* `FrostSigningManager` before it applies a batch, `ChronicleManager`
* before it applies a payload -- and a check that runs twice tends to
* become a check nobody performs. [verifies] is for readers.
*/

View File

@@ -83,10 +83,15 @@ data class MantraArtifact(
* from the same event.
*
* Tag order matches [ArtifactEvent.build] exactly -- alt first -- for the
* same reason, and `ArchiveRoundTripTest` is what says so. Both of those were
* wrong here until an archive needed to rebuild an artifact and nothing had
* same reason, and `ChronicleRoundTripTest` is what says so. Both of those were
* wrong here until a chronicle needed to rebuild an artifact and nothing had
* ever called this.
*/
@Deprecated(
"Chronicle fallback for work signed before the GroupSignedEvent table. Read the " +
"event off that table instead; this rebuild goes when the last pre-v13 install " +
"does -- see the removal checklist in docs/member-chronicle.md."
)
fun toArtifactEvent(versionLabel: String): ArtifactEvent {
return ArtifactEvent(
id = id,

View File

@@ -66,6 +66,11 @@ data class MantraArtifactVersion(
override val createdAt: Instant = Clock.System.now(),
override val updatedAt: Instant = createdAt
): OptionalNostrEventEntity, TimestampedEntity {
@Deprecated(
"Chronicle fallback for work signed before the GroupSignedEvent table. Read the " +
"event off that table instead; this rebuild goes when the last pre-v13 install " +
"does -- see the removal checklist in docs/member-chronicle.md."
)
fun toArtifactVersionEvent(): ArtifactVersionEvent {
return ArtifactVersionEvent(
id = id,
@@ -74,7 +79,7 @@ data class MantraArtifactVersion(
// Tag order matches ArtifactVersionEvent.initialVersionOf, which puts
// the alt first because `build` does, or the event id does not
// round-trip. It was the other way round here for as long as nothing
// called this -- see ArchiveRoundTripTest, which is what calls it now.
// called this -- see ChronicleRoundTripTest, which is what calls it now.
tags = TagArrayBuilder<ArtifactVersionEvent>()
.addUnique(
AltTag.assemble(ArtifactVersionEvent.ALT_DESCRIPTION)

View File

@@ -71,6 +71,11 @@ data class MantraChapter(
override val updatedAt: Instant = createdAt
): OptionalNostrEventEntity, TimestampedEntity {
@Deprecated(
"Chronicle fallback for work signed before the GroupSignedEvent table. Read the " +
"event off that table instead; this rebuild goes when the last pre-v13 install " +
"does -- see the removal checklist in docs/member-chronicle.md."
)
fun toChapterEvent(): ChapterEvent {
return ChapterEvent(
id = id,

View File

@@ -67,6 +67,11 @@ data class MantraChunk(
override val updatedAt: Instant = createdAt
): OptionalNostrEventEntity, TimestampedEntity {
@Deprecated(
"Chronicle fallback for work signed before the GroupSignedEvent table. Read the " +
"event off that table instead; this rebuild goes when the last pre-v13 install " +
"does -- see the removal checklist in docs/member-chronicle.md."
)
fun toChunkEvent(): ChunkEvent {
return ChunkEvent(
id = id,

View File

@@ -56,6 +56,11 @@ data class MantraDialect(
override val updatedAt: Instant = createdAt
): OptionalNostrEventEntity, TimestampedEntity {
@Deprecated(
"Chronicle fallback for work signed before the GroupSignedEvent table. Read the " +
"event off that table instead; this rebuild goes when the last pre-v13 install " +
"does -- see the removal checklist in docs/member-chronicle.md."
)
fun toDialectEvent(): DialectEvent {
return DialectEvent(
id = id,

View File

@@ -75,6 +75,11 @@ data class MantraTranslationArtifactVersion(
override val updatedAt: Instant = createdAt
): OptionalNostrEventEntity, TimestampedEntity {
@Deprecated(
"Chronicle fallback for work signed before the GroupSignedEvent table. Read the " +
"event off that table instead; this rebuild goes when the last pre-v13 install " +
"does -- see the removal checklist in docs/member-chronicle.md."
)
fun toTranslationArtifactVersionEvent(): TranslationArtifactVersionEvent {
return TranslationArtifactVersionEvent(
id = id,

View File

@@ -70,6 +70,11 @@ data class MantraTranslationChapter(
override val createdAt: Instant = Clock.System.now(),
override val updatedAt: Instant = createdAt
): OptionalNostrEventEntity, TimestampedEntity {
@Deprecated(
"Chronicle fallback for work signed before the GroupSignedEvent table. Read the " +
"event off that table instead; this rebuild goes when the last pre-v13 install " +
"does -- see the removal checklist in docs/member-chronicle.md."
)
fun toTranslationChapterEvent(): TranslationChapterEvent {
return TranslationChapterEvent(
id = id,

View File

@@ -72,6 +72,11 @@ data class MantraTranslationChunk(
override val updatedAt: Instant = createdAt
): OptionalNostrEventEntity, TimestampedEntity {
@Deprecated(
"Chronicle fallback for work signed before the GroupSignedEvent table. Read the " +
"event off that table instead; this rebuild goes when the last pre-v13 install " +
"does -- see the removal checklist in docs/member-chronicle.md."
)
fun toTranslationChunkEvent(): TranslationChunkEvent {
return TranslationChunkEvent(
id = id,

View File

@@ -2,7 +2,7 @@ package press.mantra.compose.database.repository
import press.mantra.compose.database.GENESIS_AT
import press.mantra.compose.database.MantraDatabase
import press.mantra.compose.managers.ArchiveManager
import press.mantra.compose.managers.ChronicleManager
import press.mantra.compose.database.model.ChatMessage
import press.mantra.compose.database.model.ChatRoom
import press.mantra.compose.database.model.GiftWrapPayload
@@ -55,7 +55,7 @@ class DatabaseChatRepository(
override suspend fun requestGroupHistoryIfMissing(
chatRoomId: String,
userPublicKey: HexKey
): Boolean = ArchiveManager.requestIfEmpty(
): Boolean = ChronicleManager.requestIfEmpty(
database = database,
chatRoomId = chatRoomId,
userPublicKey = userPublicKey,

View File

@@ -15,20 +15,20 @@ import press.mantra.compose.database.model.MarmotInnerEvent
import press.mantra.compose.extensions.toHex
import kotlin.time.Clock
import kotlin.time.Instant
import press.mantra.compose.nostr.archive.ArchiveEvent
import press.mantra.compose.nostr.archive.ArchiveRequestEvent
import press.mantra.compose.nostr.chronicle.ChronicleEvent
import press.mantra.compose.nostr.chronicle.ChronicleRequestEvent
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
import press.mantra.compose.nostr.nip30303.TranslationChunkEvent
/**
* Building the group's signed record into pages a member who lacks it can apply.
*
* See docs/member-archive.md. The short of it: a member added after the work was
* See docs/member-chronicle.md. The short of it: a member added after the work was
* done has none of it and never will, because a group-signed event is applied
* locally by each device that took part and never goes on the wire. This is how
* it gets to them.
*
* ### The events are the archive, and the rows are the fallback
* ### The events are the chronicle, and the rows are the fallback
*
* `GroupSignedEvent` holds what the group signed, as it signed it, so
* [signedEventsOf] reads it first: no rebuild, no round-trip risk, and nothing
@@ -40,31 +40,33 @@ import press.mantra.compose.nostr.nip30303.TranslationChunkEvent
* A room whose work predates that table has no events on file, so the rebuild
* stays as the fallback for exactly what the table is missing, keyed by id.
* Every payload it produces stands or falls on being byte-identical to what was
* signed; `ArchiveRoundTripTest` is what says it is, per kind, against a real
* signed; `ChronicleRoundTripTest` is what says it is, per kind, against a real
* quorum, and it found two faults in the artifact's rebuild the first time it
* ran -- both of which would have shipped payloads that every receiver drops as
* forgeries without a word. The fallback can go once no install still holds
* pre-v13 work.
* forgeries without a word. Both it and everything that exists only to hold it
* up are marked `@Deprecated`; the fallback can go once no install still holds
* pre-v13 work, and docs/member-chronicle.md's "Retiring the rebuild" is the list
* of what goes with it.
*
* **The allowlist does real work on the way out now.** The rebuild could only
* ever produce document kinds; the table holds everything the group has ever
* signed, `GroupKeyStateEvent` included -- and every room signs one of those as
* its first act. So [signedEventsOf] filters on [ArchiveEvent.isArchivable]
* its first act. So [signedEventsOf] filters on [ChronicleEvent.isChroniclable]
* before anything else, which is the same rule [applyPage] applies on the way
* in. Without it `ArchiveEvent.build` would refuse the page, and a room's whole
* archive would fail on the one event every room has.
* in. Without it `ChronicleEvent.build` would refuse the page, and a room's whole
* chronicle would fail on the one event every room has.
*
* ### Nothing unverifiable leaves
*
* Every rebuilt event is checked with [GroupKeyStateEvent.isSignedByRoom] before
* it is packed, against the same room id the recipient will check it with. That
* is not politeness towards the receiver, who checks anyway. It is what keeps an
* archive honest about its own size: a row that came from a member's rumor
* cannot be archived, and dropping it here rather than letting the recipient
* chronicle honest about its own size: a row that came from a member's rumor
* cannot be chronicled, and dropping it here rather than letting the recipient
* drop it means the page count says what will actually arrive.
*/
object ArchiveManager {
private const val TAG = "ArchiveManager"
object ChronicleManager {
private const val TAG = "ChronicleManager"
private val logger = Logger.withTag(TAG)
@@ -82,9 +84,9 @@ object ArchiveManager {
database: MantraDatabase,
chatRoomId: String,
recipient: HexKey,
archiveId: String = RandomInstance.bytes(32).toHex(),
chronicleId: String = RandomInstance.bytes(32).toHex(),
createdAt: Long = TimeUtils.now(),
): List<EventTemplate<ArchiveEvent>> {
): List<EventTemplate<ChronicleEvent>> {
val held = signedEventsOf(database, chatRoomId)
// One rule over both sources: nothing leaves that the recipient could
@@ -98,25 +100,25 @@ object ArchiveManager {
if (verified.size != held.size) {
logger.d(
"Leaving ${held.size - verified.size} of ${held.size} event(s) out of " +
"$chatRoomId's archive: nothing verifiably signed by the room"
"$chatRoomId's chronicle: nothing verifiably signed by the room"
)
}
val pages = paginate(ArchiveEvent.inApplyOrder(verified))
val pages = paginate(ChronicleEvent.inApplyOrder(verified))
if (pages.isEmpty()) {
logger.i("Nothing signed to archive for room $chatRoomId")
logger.i("Nothing signed to chronicle for room $chatRoomId")
return emptyList()
}
logger.i(
"Archiving ${verified.size} event(s) for $chatRoomId as $archiveId, " +
"Chronicling ${verified.size} event(s) for $chatRoomId as $chronicleId, " +
"${pages.size} page(s) for ${recipient.take(8)}"
)
return pages.mapIndexed { index, payloads ->
ArchiveEvent.build(
ChronicleEvent.build(
payloads = payloads,
archiveId = archiveId,
chronicleId = chronicleId,
index = index,
count = pages.size,
recipient = recipient,
@@ -155,7 +157,7 @@ object ArchiveManager {
userPublicKey: HexKey,
): Boolean {
val chatRoom = database.chatRoomDao().findChatRoomById(chatRoomId)?.chatRoom ?: return false
if (chatRoom.archiveRequestedAt != null) return false
if (chatRoom.chronicleRequestedAt != null) return false
val holdsWork = database.mantraDialectDao().getDialectsByChatRoomId(chatRoomId).isNotEmpty() ||
database.mantraArtifactDao().getArtifactsByChatRoomId(chatRoomId).isNotEmpty()
@@ -166,18 +168,18 @@ object ArchiveManager {
database = database,
chatRoomId = chatRoomId,
userPublicKey = userPublicKey,
kind = ArchiveRequestEvent.KIND,
tags = ArchiveRequestEvent.build().tags,
kind = ChronicleRequestEvent.KIND,
tags = ChronicleRequestEvent.build().tags,
content = "",
)
database.chatRoomDao().upsert(chatRoom.copy(archiveRequestedAt = Clock.System.now()))
database.chatRoomDao().upsert(chatRoom.copy(chronicleRequestedAt = Clock.System.now()))
announce(
database = database,
chatRoomId = chatRoomId,
userPublicKey = userPublicKey,
messageType = ChatMessage.TYPE_ARCHIVE_REQUESTED,
messageType = ChatMessage.TYPE_CHRONICLE_REQUESTED,
content = "Asked this group for its signed work",
)
@@ -189,7 +191,7 @@ object ArchiveManager {
/**
* Send [recipient] everything this device can prove about [chatRoomId].
*
* Both ways an archive goes out are this one call: answering a request, and
* Both ways a chronicle goes out are this one call: answering a request, and
* the push behind a Welcome. Named for what it does rather than for either
* occasion, because the two differ only in who decided.
*
@@ -200,7 +202,7 @@ object ArchiveManager {
* a correctness gap to close first.
*
* A device with nothing signed sends nothing. Silence is the honest reply
* from a member who is themselves still catching up, and an empty archive
* from a member who is themselves still catching up, and an empty chronicle
* would look like an answer.
*/
suspend fun sendTo(
@@ -237,11 +239,11 @@ object ArchiveManager {
database = database,
chatRoomId = chatRoomId,
userPublicKey = userPublicKey,
messageType = ChatMessage.TYPE_ARCHIVE_SENT,
messageType = ChatMessage.TYPE_CHRONICLE_SENT,
content = "Sent this group's signed work to $name",
)
logger.i("Sending ${recipient.take(8)} ${pages.size} archive page(s) for $chatRoomId")
logger.i("Sending ${recipient.take(8)} ${pages.size} chronicle page(s) for $chatRoomId")
return pages.size
}
@@ -250,12 +252,12 @@ object ArchiveManager {
* One line in the room's transcript.
*
* Written by the device it happened on, for itself. None of these travels --
* an archive is not an event in the group's life, it is one member being
* a chronicle is not an event in the group's life, it is one member being
* caught up -- so a member watching from the side sees nothing, correctly.
*
* Content is a whole sentence rather than a predicate, so these stay out of
* the AUTHORED sets and nothing prefixes a name to them. See
* [ChatMessage.ARCHIVE_TYPES].
* [ChatMessage.CHRONICLE_TYPES].
*/
private suspend fun announce(
database: MantraDatabase,
@@ -288,10 +290,10 @@ object ArchiveManager {
*
* **No `ChatMessage`.** Broadcast does not depend on one -- it is
* unconditional in `encryptAndSendMarmotInnerEvent`, which is what let the
* signing sessions travel with no transcript line -- and an archive that
* signing sessions travel with no transcript line -- and a chronicle that
* filed one per page would put a row of envelopes in the room's history. One
* line per archive is right, and it is not writable from here, because the
* pages of an archive are indistinguishable from each other at this point.
* line per chronicle is right, and it is not writable from here, because the
* pages of a chronicle are indistinguishable from each other at this point.
*/
private suspend fun queue(
database: MantraDatabase,
@@ -344,7 +346,7 @@ object ArchiveManager {
}
/**
* Take delivery of an archive page for [userPublicKey].
* Take delivery of a chronicle page for [userPublicKey].
*
* The page is already on disk by the time this runs -- the inbound path
* stores every inner event it decrypts before dispatching on kind -- so this
@@ -355,7 +357,7 @@ object ArchiveManager {
* **A device that is not the named recipient does nothing.** The page is an
* ordinary group message and it can read it; it has no reason to. It already
* holds the work, and re-applying would rewrite every one of its rows to
* point at an archive page rather than at the event that introduced it. That
* point at a chronicle page rather than at the event that introduced it. That
* is also what keeps the sweep bounded: only the member being caught up ever
* builds the list.
*/
@@ -365,7 +367,7 @@ object ArchiveManager {
userPublicKey: HexKey,
page: Event,
): Outcome {
val recipient = ArchiveEvent(
val recipient = ChronicleEvent(
id = page.id,
pubKey = page.pubKey,
createdAt = page.createdAt,
@@ -375,7 +377,7 @@ object ArchiveManager {
).recipient()
if (!recipient.equals(userPublicKey, ignoreCase = true)) {
logger.d("Archive page ${page.id.take(8)} is for ${recipient?.take(8)}; not applying")
logger.d("Chronicle page ${page.id.take(8)} is for ${recipient?.take(8)}; not applying")
return Outcome()
}
@@ -383,7 +385,7 @@ object ArchiveManager {
}
/**
* Apply every archive page this room holds for [userPublicKey], repeatedly,
* Apply every chronicle page this room holds for [userPublicKey], repeatedly,
* until a pass stops making progress.
*
* Pages arrive over relays in no order, so page 3 can land before page 2 and
@@ -399,8 +401,8 @@ object ArchiveManager {
* write here is an upsert keyed on the event id, so "applied something" is
* true on every pass forever and would not terminate. A pass that fails fewer
* payloads than the last one learned something; a pass that does not is as
* far as this archive gets, and the rest is a hole to be filled by another
* page or another archive.
* far as this chronicle gets, and the rest is a hole to be filled by another
* page or another chronicle.
*
* **The answer is the last pass, not the sum of them.** Accumulating would
* count a payload once per pass it survived and report failures the next pass
@@ -414,7 +416,7 @@ object ArchiveManager {
userPublicKey: HexKey,
): Outcome {
val pages = database.marmotInnerEventDao()
.getByChatRoomAndKinds(chatRoomId, listOf(ArchiveEvent.KIND))
.getByChatRoomAndKinds(chatRoomId, listOf(ChronicleEvent.KIND))
.filter { addressedTo(it, userPublicKey) }
if (pages.isEmpty()) return Outcome()
@@ -440,22 +442,22 @@ object ArchiveManager {
}
logger.i(
"Swept ${pages.size} archive page(s) for $chatRoomId: " +
"Swept ${pages.size} chronicle page(s) for $chatRoomId: " +
"${pass.applied} applied, ${pass.skipped} skipped, ${pass.failed} left"
)
// An answer arrived, so the room may ask again if it turns out to be a
// partial one. Cleared on anything applied rather than on the archive
// partial one. Cleared on anything applied rather than on the chronicle
// reporting itself complete: a page count is the sender's claim about the
// transfer, not about the group's record, and a member who left work out
// would otherwise have the last word.
if (pass.applied > 0) {
database.chatRoomDao().findChatRoomById(chatRoomId)?.chatRoom?.let { chatRoom ->
if (chatRoom.archiveRequestedAt != null) {
database.chatRoomDao().upsert(chatRoom.copy(archiveRequestedAt = null))
if (chatRoom.chronicleRequestedAt != null) {
database.chatRoomDao().upsert(chatRoom.copy(chronicleRequestedAt = null))
// One line per answered request, which is as close to one per
// archive as this can get: the pages of an archive are not
// chronicle as this can get: the pages of a chronicle are not
// distinguishable from each other here, and clearing the stamp
// is exactly the moment a catch-up stops being pending.
//
@@ -467,7 +469,7 @@ object ArchiveManager {
database = database,
chatRoomId = chatRoomId,
userPublicKey = userPublicKey,
messageType = ChatMessage.TYPE_ARCHIVE_RECEIVED,
messageType = ChatMessage.TYPE_CHRONICLE_RECEIVED,
content = "Caught up on ${pass.applied} item(s) of this group's signed work",
)
}
@@ -478,7 +480,7 @@ object ArchiveManager {
}
private fun addressedTo(stored: MarmotInnerEvent, userPublicKey: HexKey): Boolean =
ArchiveEvent(
ChronicleEvent(
id = stored.id,
pubKey = stored.publicKey,
createdAt = stored.createdAt.epochSeconds,
@@ -495,42 +497,42 @@ object ArchiveManager {
* for a forged direct message, and for the same reason: this runs inside the
* inbound transaction, and one bad event must not take the room down with it.
* Refusing the whole page would also let a single forgery deny an entire
* archive.
* chronicle.
*
* The page's own framing is still all-or-nothing; see
* [ArchiveEvent.decodePage] for why those two are not in tension.
* [ChronicleEvent.decodePage] for why those two are not in tension.
*/
private suspend fun applyPage(
database: MantraDatabase,
chatRoomId: String,
stored: MarmotInnerEvent,
): Outcome {
val payloads = ArchiveEvent.decodePage(stored.content)
val payloads = ChronicleEvent.decodePage(stored.content)
if (payloads == null) {
logger.w("Archive page ${stored.id.take(8)} does not read as a page; dropping it")
logger.w("Chronicle page ${stored.id.take(8)} does not read as a page; dropping it")
return Outcome()
}
// Read once for the page rather than per payload: every event in an
// archive was signed by the room it is arriving in, so they all share
// chronicle was signed by the room it is arriving in, so they all share
// its path. Null when the recipient has no key state yet -- which is
// the normal case for the member an archive exists for, and why
// the normal case for the member a chronicle exists for, and why
// `GroupSignedEvent.derivationPath` is nullable and `record` fills it
// in later rather than overwriting it with null.
val derivationPath = GroupKeyStateManager.keyStateFor(database, chatRoomId)?.derivationPath
var outcome = Outcome()
ArchiveEvent.inApplyOrder(payloads).forEach { payload ->
ChronicleEvent.inApplyOrder(payloads).forEach { payload ->
// The allowlist first, and it is not a formality. Verification admits
// an event to the apply path on the strength of the group's
// signature, which makes every kind the group has ever signed
// replayable by any member at any time -- a GroupKeyStateEvent from
// an earlier epoch passes the signature check perfectly.
if (!ArchiveEvent.isArchivable(payload.kind)) {
if (!ChronicleEvent.isChroniclable(payload.kind)) {
logger.w(
"Archive page ${stored.id.take(8)} carries kind ${payload.kind}, " +
"which an archive may not deliver; dropping ${payload.id.take(8)}"
"Chronicle page ${stored.id.take(8)} carries kind ${payload.kind}, " +
"which a chronicle may not deliver; dropping ${payload.id.take(8)}"
)
outcome += Outcome(skipped = 1)
return@forEach
@@ -538,7 +540,7 @@ object ArchiveManager {
if (!GroupKeyStateEvent.isSignedByRoom(payload, chatRoomId)) {
logger.w(
"Archive page ${stored.id.take(8)} carries ${payload.id.take(8)}, " +
"Chronicle page ${stored.id.take(8)} carries ${payload.id.take(8)}, " +
"which room $chatRoomId did not sign; dropping it"
)
outcome += Outcome(skipped = 1)
@@ -551,7 +553,7 @@ object ArchiveManager {
// is one the room signed, which is the same standard
// `FrostSigningManager` records its own batches on. Without this
// a member who was handed their history would hold the rows and
// none of the events, and could never build an archive of their
// none of the events, and could never build a chronicle of their
// own to hand on.
database.groupSignedEventDao().record(
GroupSignedEvent.fromEvent(
@@ -565,7 +567,7 @@ object ArchiveManager {
// filed. ChatMessage has an autoGenerate primary key, so there is
// no id to dedupe on and every applied payload would mint a new
// row -- giving the recipient a synthetic transcript dated now,
// and another one on every pass of the sweep. The archive
// and another one on every pass of the sweep. The chronicle
// restores the work; the conversation is forward secret and stays
// gone.
ChatMessage.applyInnerEvent(
@@ -585,7 +587,7 @@ object ArchiveManager {
// in a page which has not arrived yet. Retryable, which is what
// the sweep is for, so it is counted rather than logged loudly.
logger.d(
"Could not apply ${payload.id.take(8)} from archive page " +
"Could not apply ${payload.id.take(8)} from chronicle page " +
"${stored.id.take(8)} yet: ${error.message}"
)
Outcome(failed = 1)
@@ -596,7 +598,7 @@ object ArchiveManager {
}
/**
* Every archivable event this device holds for the room: the ones the group
* Every chroniclable event this device holds for the room: the ones the group
* signed here or sent here, plus anything only the rows still remember.
*
* The record comes first because it is the event rather than a reconstruction
@@ -611,7 +613,7 @@ object ArchiveManager {
* verify, so the filter in [assemble] drops it either way rather than
* shipping a payload every receiver reads as a forgery.
*
* Order does not matter at this point; [ArchiveEvent.inApplyOrder] settles it
* Order does not matter at this point; [ChronicleEvent.inApplyOrder] settles it
* afterwards. It is stable within a rank, so a room holding some of its work
* both ways can order two chapters differently from a member holding one way
* only. That costs nothing: pages are idempotent and applied payload by
@@ -628,7 +630,7 @@ object ArchiveManager {
// see the class comment. A key state on file is the room's own, and
// sending it would be handing every member a validly signed
// statement about what the room signs with, replayable forever.
.filter { ArchiveEvent.isArchivable(it.kind) }
.filter { ChronicleEvent.isChroniclable(it.kind) }
.map { it.toEvent() }
val onFile = recorded.mapTo(mutableSetOf()) { it.id }
@@ -636,7 +638,7 @@ object ArchiveManager {
if (rebuilt.isNotEmpty()) {
logger.d(
"Archiving $chatRoomId: ${recorded.size} event(s) as the group signed them, " +
"Chronicling $chatRoomId: ${recorded.size} event(s) as the group signed them, " +
"${rebuilt.size} rebuilt from rows that predate the record"
)
}
@@ -653,15 +655,15 @@ object ArchiveManager {
* translation chunk, deleting the one it replaces, so a passage translated
* three times leaves one row and three events.
*
* That difference reaches the archive the moment it is read from the record
* That difference reaches the chronicle the moment it is read from the record
* rather than rebuilt from rows, and it compounds: every draft a group ever
* signed would travel in every archive it ever sends, for as long as the room
* exists. An archive exists to catch a member up on where the group has got
* signed would travel in every chronicle it ever sends, for as long as the room
* exists. A chronicle exists to catch a member up on where the group has got
* to, not to hand them its drafting history.
*
* **The rule is the applying arm's, restated rather than approximated**:
* newest by the timestamp the group signed at, id breaking a tie. It has to
* be, or the archive would ship one translation as current and the recipient
* be, or the chronicle would ship one translation as current and the recipient
* would settle on another -- and since both are validly signed, nothing
* downstream would notice the disagreement.
*
@@ -702,7 +704,7 @@ object ArchiveManager {
if (current.size != translations.size) {
logger.d(
"Leaving ${translations.size - current.size} superseded translation(s) " +
"out of the archive"
"out of the chronicle"
)
}
@@ -718,7 +720,7 @@ object ArchiveManager {
* artifact row -- see `MantraArtifact.toArtifactEvent` -- and the version it
* went into is one step away here.
*
* Still walked on every archive, and by now it contributes nothing in most
* Still walked on every chronicle, and by now it contributes nothing in most
* rooms: everything signed or applied since `GroupSignedEvent` existed is on
* file as an event, and [signedEventsOf] discards whatever this rebuilds of
* it. The walk is a handful of indexed queries against a room's own rows,
@@ -728,7 +730,18 @@ object ArchiveManager {
*
* It returns everything it can rebuild and lets [signedEventsOf] and the
* verify filter decide what can travel.
*
* Deprecated rather than merely legacy: it is a whole mechanism kept alive
* for a shrinking set of rows, and it takes eight `toXEvent()` methods and a
* ten-case round-trip suite with it. **docs/member-chronicle.md, "Retiring the
* rebuild", is the checklist** -- what goes, what only looks like it goes,
* and the one thing that has to be true before any of it can.
*/
@Deprecated(
"Chronicle fallback for work signed before the GroupSignedEvent table. " +
"Goes when the last pre-v13 install does -- see the removal checklist " +
"in docs/member-chronicle.md."
)
private suspend fun rebuiltEventsOf(
database: MantraDatabase,
chatRoomId: String,
@@ -746,7 +759,7 @@ object ArchiveManager {
// gives it the artifact's own timestamp. The label is still not on the
// artifact row -- `fromArtifactEvent` drops it -- so this is where it
// comes back from. An artifact with no such version is one this device
// cannot rebuild, which is a gap in the archive rather than a reason to
// cannot rebuild, which is a gap in the chronicle rather than a reason to
// abandon it.
val versionLabel = versions
.firstOrNull { it.createdAt == artifact.createdAt }
@@ -785,7 +798,7 @@ object ArchiveManager {
// is here to be found: retranslating a passage
// changes the text and so the event id, and the
// arm that applies one drops what it replaces. So
// an archive carries a group's current answer to
// a chronicle carries a group's current answer to
// each passage rather than its drafts, which is
// the same thing every other member holds.
database.mantraTranslationChunkDao()
@@ -801,12 +814,12 @@ object ArchiveManager {
* [events] cut into pages that fit, keeping the order they arrive in.
*
* Greedy: fill a page until the next event would cross either cap. Both are
* checked because they bind different archives -- a room of one-line dialects
* checked because they bind different chronicles -- a room of one-line dialects
* hits the count first and a room of chapters hits the bytes.
*
* An event too large to share a page with anything is given one of its own.
* One too large for even that is dropped with a log rather than failing the
* archive: a chapter nobody can archive is a hole, and a member who gets
* chronicle: a chapter nobody can chronicle is a hole, and a member who gets
* nothing at all is a bigger one.
*/
private fun paginate(events: List<Event>): List<List<Event>> {
@@ -819,16 +832,16 @@ object ArchiveManager {
// Plus the comma this event needs if it is not first on its page.
val size = event.toJson().encodeToByteArray().size + 1
if (2 + size > ArchiveEvent.MAX_PAGE_BYTES) {
if (2 + size > ChronicleEvent.MAX_PAGE_BYTES) {
logger.w(
"Event ${event.id} is $size bytes and will not fit a " +
"${ArchiveEvent.MAX_PAGE_BYTES}-byte page; leaving it out of the archive"
"${ChronicleEvent.MAX_PAGE_BYTES}-byte page; leaving it out of the chronicle"
)
return@forEach
}
val full = page.isNotEmpty() &&
(bytes + size > ArchiveEvent.MAX_PAGE_BYTES || page.size >= ArchiveEvent.MAX_PAGE_EVENTS)
(bytes + size > ChronicleEvent.MAX_PAGE_BYTES || page.size >= ChronicleEvent.MAX_PAGE_EVENTS)
if (full) {
pages.add(page)

View File

@@ -1,40 +0,0 @@
package press.mantra.compose.nostr.archive.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* Ties the pages of one archive together.
*
* An archive is more than one event and its pages arrive in no particular
* order, so a receiver has to be able to tell page 2 of this archive from page 2
* of somebody else's. Two members answering the same request is the ordinary
* case rather than the odd one -- nothing stops them, and nothing should, since
* an incomplete answer is exactly what a second answer fixes -- and without an
* id their pages would interleave into one sequence that is neither.
*
* Fresh random bytes per archive, not derived from anything. Two members
* assembling the same rows must not collide on an id, because their page counts
* will differ whenever their databases do.
*/
class ArchiveIdTag(
val archiveId: String,
) {
fun toTagArray() = assemble(archiveId = archiveId)
companion object {
const val TAG_NAME = "archiveId"
fun parse(tag: Array<String>): ArchiveIdTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotBlank()) { return null }
return ArchiveIdTag(archiveId = tag[1])
}
fun assemble(archiveId: String): Array<String> = arrayOf(TAG_NAME, archiveId)
fun assemble(archiveIdTag: ArchiveIdTag) = assemble(archiveId = archiveIdTag.archiveId)
}
}

View File

@@ -1,4 +1,4 @@
package press.mantra.compose.nostr.archive
package press.mantra.compose.nostr.chronicle
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -11,9 +11,9 @@ import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.serialization.json.jsonArray
import press.mantra.compose.network.serialization.CommonJson
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.nip30303.ArtifactEvent
import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.ChapterEvent
@@ -38,26 +38,26 @@ import press.mantra.compose.nostr.nip30303.TranslationChunkEvent
*
* Each payload travels whole, keeping its own id, author and signature, so the
* receiver checks it rather than believing it -- see [isSignedByRoom] on
* `GroupKeyStateEvent`. The sender of an archive is therefore not trusted, which
* `GroupKeyStateEvent`. The sender of a chronicle is therefore not trusted, which
* is what lets any member answer.
*
* See docs/member-archive.md for the whole design, including the two things this
* See docs/member-chronicle.md for the whole design, including the two things this
* deliberately does not do: it never carries the chat, and it cannot make its
* recipient able to *sign* anything.
*
* ### Why not a [press.mantra.compose.nostr.nip30303.SubmissionEvent] each
*
* The envelope fits and the meaning does not. A submission is an *act* -- this
* member is putting this event in front of this group -- and an archive asserts
* member is putting this event in front of this group -- and a chronicle asserts
* nothing; it re-delivers what the group already agreed. On one kind a
* four-hundred-event backfill is indistinguishable from four hundred new
* submissions and every device has to guess which it is looking at. It would
* also be one inner event and one kind:445 per payload, where a page is one, and
* the submission arm of `ChatMessage.applyInnerEvent` files a chat line per
* payload, which an archive must not.
* payload, which a chronicle must not.
*/
@Immutable
class ArchiveEvent(
class ChronicleEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
@@ -71,19 +71,19 @@ class ArchiveEvent(
* readable page.
*
* Nothing here is verified yet. Parsing says the framing is intact;
* `ArchiveManager` is what asks whether the group signed each one, and it
* `ChronicleManager` is what asks whether the group signed each one, and it
* asks per payload rather than per page.
*/
fun payloads(): List<Event>? = decodePage(content)
/** Which archive this page belongs to, or null if it names none. */
fun archiveId(): String? = tags.firstNotNullOfOrNull(ArchiveIdTag::parse)?.archiveId
/** Which chronicle this page belongs to, or null if it names none. */
fun chronicleId(): String? = tags.firstNotNullOfOrNull(ChronicleIdTag::parse)?.chronicleId
/** Where this page sits in that archive, or null if it does not say. */
fun page(): ArchivePageTag? = tags.firstNotNullOfOrNull(ArchivePageTag::parse)
/** Where this page sits in that chronicle, or null if it does not say. */
fun page(): ChroniclePageTag? = tags.firstNotNullOfOrNull(ChroniclePageTag::parse)
/** Who should act on this page. A hint -- see [ArchiveRecipientTag]. */
fun recipient(): HexKey? = tags.firstNotNullOfOrNull(ArchiveRecipientTag::parse)?.pubKey
/** Who should act on this page. A hint -- see [ChronicleRecipientTag]. */
fun recipient(): HexKey? = tags.firstNotNullOfOrNull(ChronicleRecipientTag::parse)?.pubKey
companion object {
/**
@@ -97,13 +97,13 @@ class ArchiveEvent(
* instead, which is "an accident of routing rather than a decision, and
* the next family added should not rely on it." This is that next family.
*
* It is also the right neighbourhood on the merits. An archive is not a
* 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 a key
* state is too.
*/
const val KIND: Kind = 30327
const val ALT_DESCRIPTION = "Group archive"
const val ALT_DESCRIPTION = "Group chronicle"
/**
* The most JSON one page may carry.
@@ -139,18 +139,18 @@ class ArchiveEvent(
*
* Larger than `FrostSigningManager.MAX_BATCH_SIZE`, deliberately: a batch
* item costs nonce generation, a FROST session and a native sign, where
* an archive payload costs a signature verify and an upsert.
* a chronicle payload costs a signature verify and an upsert.
*/
const val MAX_PAGE_EVENTS = 128
/**
* The kinds an archive may carry, in the order they have to be applied.
* The kinds a chronicle may carry, in the order they have to be applied.
*
* One list doing both jobs, because a separate allowlist is one more
* thing that can disagree with the order it is applied in.
*
* **The order is Room's, not nostr's.** Every one of these has a foreign
* key on the one before it, so an archive applied out of order is a
* key on the one before it, so a chronicle applied out of order is a
* constraint violation rather than a wrong answer. Kind order is not
* dependency order and never was -- a translation chapter (30308) hangs
* off a translation artifact version (30306) which hangs off an artifact
@@ -160,13 +160,13 @@ class ArchiveEvent(
* path on the strength of the group's signature, which makes every kind
* the group has ever signed replayable by any member at any time. A
* `GroupKeyStateEvent` is group-signed and passes verification perfectly,
* so an archive carrying an old one is a validly signed statement about
* 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 list stops it.
*
* ### What is missing from it, and why
*
* **Only kinds the group actually signs can be here**, because an archive
* **Only kinds the group actually signs can be here**, because a chronicle
* that cannot be verified is one that has to be believed. Six of the
* thirteen nip30303 kinds reach a signing session; the rest travel as
* rumors -- empty signature, member author, vouched for by the MLS frame
@@ -177,7 +177,7 @@ class ArchiveEvent(
* - `TranslationEvent` (30311). Nothing builds one; the inbound arm
* exists and no producer does.
* - The contributor lists (30305, 30307, 30310). `applyInnerEvent` has
* no arm that writes a row for any of them, so archiving them would
* no arm that writes a row for any of them, so chronicling them would
* cost bytes and restore nothing.
*
* Two kinds were on that list and are not any more, because the app
@@ -186,7 +186,7 @@ class ArchiveEvent(
* of the batch that carries it. `TranslationChunkEvent` (30309) -- the
* translated text itself -- used to be submitted as its author's rumor,
* and is now proposed to the group like everything else. Both are
* therefore checkable, and an archive that left them out would hand a new
* therefore checkable, and a chronicle that left them out would hand a new
* member the whole structure and none of the prose.
*/
private val APPLY_ORDER: List<Kind> = listOf(
@@ -200,23 +200,23 @@ class ArchiveEvent(
TranslationChunkEvent.KIND,
)
/** Every kind an archive may carry. */
val ARCHIVABLE_KINDS: Set<Kind> = APPLY_ORDER.toSet()
/** Every kind a chronicle may carry. */
val CHRONICLABLE_KINDS: Set<Kind> = APPLY_ORDER.toSet()
/**
* Where [kind] sits in dependency order, or null if an archive may not
* Where [kind] sits in dependency order, or null if a chronicle may not
* carry it at all.
*/
fun applyRank(kind: Kind): Int? = APPLY_ORDER.indexOf(kind).takeIf { it >= 0 }
fun isArchivable(kind: Kind): Boolean = kind in ARCHIVABLE_KINDS
fun isChroniclable(kind: Kind): Boolean = kind in CHRONICLABLE_KINDS
/**
* [payloads] in dependency order, so a page applies front to back.
*
* Stable within a rank: two chapters of one version have no order between
* them and keeping the caller's is one less thing that varies between two
* members assembling the same archive.
* members assembling the same chronicle.
*/
fun inApplyOrder(payloads: List<Event>): List<Event> =
payloads.sortedBy { applyRank(it.kind) ?: Int.MAX_VALUE }
@@ -239,14 +239,14 @@ class ArchiveEvent(
* payloads one at a time.** They are different questions. A page that
* will not parse has lost its framing, so nothing in it can be trusted to
* be what the sender wrote -- and a page silently shortened by one
* element would leave a receiver believing it holds a complete archive
* element would leave a receiver believing it holds a complete chronicle
* when the page count says so and the contents do not. A payload whose
* signature does not verify is a well-framed page containing one bad
* event, and costing its honest neighbours would let one forged payload
* deny an entire archive.
* deny an entire chronicle.
*
* Both caps are checked here rather than at the call site, because this
* is the boundary a remote party's page crosses. An archive is the second
* is the boundary a remote party's page crosses. A chronicle is the second
* place in this protocol where somebody else decides how much work this
* device does; the first grew a cap on the way in for the same reason.
*/
@@ -263,7 +263,7 @@ class ArchiveEvent(
}
/**
* A page of [payloads], page [index] of [count] in archive [archiveId],
* A page of [payloads], page [index] of [count] in chronicle [chronicleId],
* for [recipient].
*
* The caller sorts and pages; this only writes what it is handed. Both
@@ -272,31 +272,31 @@ class ArchiveEvent(
*/
fun build(
payloads: List<Event>,
archiveId: String,
chronicleId: String,
index: Int,
count: Int,
recipient: HexKey,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<ArchiveEvent>.() -> Unit = {},
): EventTemplate<ArchiveEvent> {
require(payloads.isNotEmpty()) { "An archive page carries at least one event" }
initializer: TagArrayBuilder<ChronicleEvent>.() -> Unit = {},
): EventTemplate<ChronicleEvent> {
require(payloads.isNotEmpty()) { "A chronicle page carries at least one event" }
require(payloads.size <= MAX_PAGE_EVENTS) {
"An archive page carries at most $MAX_PAGE_EVENTS events, not ${payloads.size}"
"A chronicle page carries at most $MAX_PAGE_EVENTS events, not ${payloads.size}"
}
payloads.forEach {
require(isArchivable(it.kind)) { "An archive may not carry kind ${it.kind}" }
require(isChroniclable(it.kind)) { "A chronicle may not carry kind ${it.kind}" }
}
val content = encodePage(payloads)
require(content.encodeToByteArray().size <= MAX_PAGE_BYTES) {
"An archive page carries at most $MAX_PAGE_BYTES bytes"
"A chronicle page carries at most $MAX_PAGE_BYTES bytes"
}
return eventTemplate<ArchiveEvent>(KIND, content, createdAt) {
return eventTemplate<ChronicleEvent>(KIND, content, createdAt) {
alt(ALT_DESCRIPTION)
addUnique(ArchiveIdTag.assemble(archiveId))
addUnique(ArchivePageTag.assemble(index, count))
addUnique(ArchiveRecipientTag.assemble(recipient))
addUnique(ChronicleIdTag.assemble(chronicleId))
addUnique(ChroniclePageTag.assemble(index, count))
addUnique(ChronicleRecipientTag.assemble(recipient))
initializer()
}
}

View File

@@ -1,4 +1,4 @@
package press.mantra.compose.nostr.archive
package press.mantra.compose.nostr.chronicle
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -16,7 +16,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils
* "I hold none of this group's work. Send it."
*
* The reliable half of handing a new member the group's history, and the reason
* [ArchiveEvent] is not simply pushed at them when they are invited.
* [ChronicleEvent] is not simply pushed at them when they are invited.
*
* A push from the inviter is an application message in the epoch the add
* created. If it reaches the invitee before their Welcome does -- different
@@ -35,7 +35,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils
*
* - a reinstall, whose rows are gone and whose invite is long past;
* - a second device, which was never invited at all;
* - an archive that was sent and lost.
* - a chronicle that was sent and lost.
*
* Carries nothing. The room is the envelope, the asker is the MLS sender, and
* what they are missing is "all of it" -- a cursor would have to be a position
@@ -43,7 +43,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils
* the first version.
*/
@Immutable
class ArchiveRequestEvent(
class ChronicleRequestEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
@@ -53,15 +53,15 @@ class ArchiveRequestEvent(
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
companion object {
/** Beside [ArchiveEvent] at 30327 -- see the note on its own kind. */
/** Beside [ChronicleEvent] at 30327 -- see the note on its own kind. */
const val KIND: Kind = 30328
const val ALT_DESCRIPTION = "Group archive request"
const val ALT_DESCRIPTION = "Group chronicle request"
fun build(
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<ArchiveRequestEvent>.() -> Unit = {},
): EventTemplate<ArchiveRequestEvent> =
initializer: TagArrayBuilder<ChronicleRequestEvent>.() -> Unit = {},
): EventTemplate<ChronicleRequestEvent> =
eventTemplate(KIND, "", createdAt) {
alt(ALT_DESCRIPTION)
initializer()

View File

@@ -0,0 +1,40 @@
package press.mantra.compose.nostr.chronicle.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* Ties the pages of one chronicle together.
*
* A chronicle is more than one event and its pages arrive in no particular
* order, so a receiver has to be able to tell page 2 of this chronicle from page 2
* of somebody else's. Two members answering the same request is the ordinary
* case rather than the odd one -- nothing stops them, and nothing should, since
* an incomplete answer is exactly what a second answer fixes -- and without an
* id their pages would interleave into one sequence that is neither.
*
* Fresh random bytes per chronicle, not derived from anything. Two members
* assembling the same rows must not collide on an id, because their page counts
* will differ whenever their databases do.
*/
class ChronicleIdTag(
val chronicleId: String,
) {
fun toTagArray() = assemble(chronicleId = chronicleId)
companion object {
const val TAG_NAME = "chronicleId"
fun parse(tag: Array<String>): ChronicleIdTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotBlank()) { return null }
return ChronicleIdTag(chronicleId = tag[1])
}
fun assemble(chronicleId: String): Array<String> = arrayOf(TAG_NAME, chronicleId)
fun assemble(chronicleIdTag: ChronicleIdTag) = assemble(chronicleId = chronicleIdTag.chronicleId)
}
}

View File

@@ -1,15 +1,15 @@
package press.mantra.compose.nostr.archive.tags
package press.mantra.compose.nostr.chronicle.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* Where this page sits in its archive, and how many there are.
* Where this page sits in its chronicle, and how many there are.
*
* The count is what lets a receiver say whether it holds a whole archive, which
* The count is what lets a receiver say whether it holds a whole chronicle, which
* is the only question it can answer about completeness on its own. It cannot
* tell whether the *sender* left anything out -- see the note on omission in
* docs/member-archive.md -- so this is a claim about the transfer, not about the
* docs/member-chronicle.md -- so this is a claim about the transfer, not about the
* group's record.
*
* The index is not an ordering instruction. Pages apply in whatever order they
@@ -17,38 +17,38 @@ import com.vitorpamplona.quartz.utils.ensure
* give no ordering guarantee and a design that needed one would be wrong on the
* wire rather than merely slow.
*/
class ArchivePageTag(
class ChroniclePageTag(
val index: Int,
val count: Int,
) {
/** Whether this page claims to be the whole archive on its own. */
/** Whether this page claims to be the whole chronicle on its own. */
fun isOnlyPage(): Boolean = count == 1
fun toTagArray() = assemble(index = index, count = count)
companion object {
const val TAG_NAME = "archivePage"
const val TAG_NAME = "chroniclePage"
fun parse(tag: Array<String>): ArchivePageTag? {
fun parse(tag: Array<String>): ChroniclePageTag? {
ensure(tag.has(2)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
val index = tag[1].toIntOrNull() ?: return null
val count = tag[2].toIntOrNull() ?: return null
// A page outside its own archive is not a page. Refused rather than
// A page outside its own chronicle is not a page. Refused rather than
// clamped: the pair is how a receiver decides it has everything, and
// a repaired one would let a truncated archive read as complete.
// a repaired one would let a truncated chronicle read as complete.
ensure(count >= 1) { return null }
ensure(index in 0 until count) { return null }
return ArchivePageTag(index = index, count = count)
return ChroniclePageTag(index = index, count = count)
}
fun assemble(index: Int, count: Int): Array<String> =
arrayOf(TAG_NAME, index.toString(), count.toString())
fun assemble(archivePageTag: ArchivePageTag) =
assemble(index = archivePageTag.index, count = archivePageTag.count)
fun assemble(chroniclePageTag: ChroniclePageTag) =
assemble(index = chroniclePageTag.index, count = chroniclePageTag.count)
}
}

View File

@@ -1,25 +1,25 @@
package press.mantra.compose.nostr.archive.tags
package press.mantra.compose.nostr.chronicle.tags
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* Who an archive is for.
* Who a chronicle is for.
*
* **A hint, not access control.** The page is an ordinary group message and
* every member can read it -- which is right, because it is their own history
* going back to them. What this decides is who *acts*: a device that is not
* named stores the page and applies nothing, since it already holds the work and
* re-applying would rewrite every one of its rows to point at an archive page
* re-applying would rewrite every one of its rows to point at a chronicle page
* rather than at the event that actually introduced it.
*
* So this is not a secret being kept from the group, and nothing downstream may
* treat it as one. Encrypting an archive to one member was considered and
* rejected in docs/member-archive.md: it protects nothing and costs a "sent a
* treat it as one. Encrypting a chronicle to one member was considered and
* rejected in docs/member-chronicle.md: it protects nothing and costs a "sent a
* private message" line per page in everyone's transcript.
*/
class ArchiveRecipientTag(
class ChronicleRecipientTag(
val pubKey: HexKey,
) {
fun toTagArray() = assemble(pubKey = pubKey)
@@ -27,17 +27,17 @@ class ArchiveRecipientTag(
companion object {
const val TAG_NAME = "p"
fun parse(tag: Array<String>): ArchiveRecipientTag? {
fun parse(tag: Array<String>): ChronicleRecipientTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
return ArchiveRecipientTag(pubKey = tag[1])
return ChronicleRecipientTag(pubKey = tag[1])
}
fun assemble(pubKey: HexKey): Array<String> = arrayOf(TAG_NAME, pubKey)
fun assemble(archiveRecipientTag: ArchiveRecipientTag) =
assemble(pubKey = archiveRecipientTag.pubKey)
fun assemble(chronicleRecipientTag: ChronicleRecipientTag) =
assemble(pubKey = chronicleRecipientTag.pubKey)
}
}

View File

@@ -144,7 +144,7 @@ object GroupKeyStateEvent {
* That is what makes a group's signature checkable by a member holding
* nothing else. No `GroupKeyState` row, no threshold key, no derivation path,
* no ceremony -- which is exactly the position a member added after the
* ceremony is in, and the reason `docs/member-archive.md` can hand them
* ceremony is in, and the reason `docs/member-chronicle.md` can hand them
* history without asking them to trust whoever sent it.
*
* Everything is caught, because every input is off the wire: a pubkey that

View File

@@ -25,7 +25,7 @@ import press.mantra.compose.nostr.nip30303.tags.PayloadKindTag
* That buys two things:
*
* - A group can take in work written by somebody who is not in it. A
* translation lifted from a public archive, a chapter transcribed by an
* translation lifted from a public chronicle, a chapter transcribed by an
* outside contributor, an artifact somebody published years ago -- an admin
* submits it and the group applies it, with the original author still named
* on the row.

View File

@@ -27,7 +27,7 @@ interface ChatRepository {
*
* True when a request went out. Safe to call on every open: it is a no-op
* for a room that already holds work and for one still waiting on an answer.
* See docs/member-archive.md for why the joiner asks rather than the inviter
* See docs/member-chronicle.md for why the joiner asks rather than the inviter
* pushing.
*/
suspend fun requestGroupHistoryIfMissing(chatRoomId: String, userPublicKey: HexKey): Boolean

View File

@@ -165,7 +165,7 @@ class ChatMessageListViewModel(
*
* Opening the room is the trigger because it is the first moment this device
* is demonstrably in the group's current epoch -- see
* docs/member-archive.md. A member added after the work was done has no way
* docs/member-chronicle.md. A member added after the work was done has no way
* to see any of it otherwise: MLS gives them no history, and a group-signed
* event never travels, so every device derives it or does without.
*
@@ -414,7 +414,7 @@ class ChatMessageListViewModel(
// Passed as answered and settled because those
// are about requests and this asks nothing --
// which is what keeps it in the quiet tint.
if (localChatMessage.chatMessage.messageType in ChatMessage.ARCHIVE_TYPES) {
if (localChatMessage.chatMessage.messageType in ChatMessage.CHRONICLE_TYPES) {
RitualNotice(
localChatMessage = localChatMessage,
isAnswered = true,
@@ -741,9 +741,9 @@ private fun RitualNotice(
ChatMessage.TYPE_FROST_FAILED -> Icons.Default.ErrorOutline
ChatMessage.TYPE_FROST_APPROVAL_NEEDED -> Icons.Default.Draw
ChatMessage.TYPE_ARCHIVE_REQUESTED -> Icons.Default.History
ChatMessage.TYPE_ARCHIVE_SENT -> Icons.Default.Upload
ChatMessage.TYPE_ARCHIVE_RECEIVED -> Icons.Default.Download
ChatMessage.TYPE_CHRONICLE_REQUESTED -> Icons.Default.History
ChatMessage.TYPE_CHRONICLE_SENT -> Icons.Default.Upload
ChatMessage.TYPE_CHRONICLE_RECEIVED -> Icons.Default.Download
else -> Icons.Default.PanTool
}