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:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user