This is the template in Documenting architecture decisions - Michael Nygard. You can use adr-tools for managing the ADR files.
In each ADR file, write these sections:
What is the status, such as proposed, accepted, rejected, deprecated, superseded, etc.?
What is the issue that we're seeing that is motivating this decision or change?
What is the change that we're proposing and/or doing?
What becomes easier or more difficult to do because of this change?
Date: 2025-12-03
Accepted
We need to document architectural decisions made during the development of the project in Kalium. This will help us keep track of the reasoning behind our decisions and provide context for future developers working on the project.
We will use Architecture Decision Records in the code and as part of the review process. We will use the Lightway ADR template to keep the ADRs simple and easy to maintain.
docs/adr, to keep the architecture decision
records.Date: 2025-11-18
Accepted
The Kalium persistence layer previously used 10 separate tables to store different types of system message content:
This design led to several issues:
MessageDetailsView required a ton LEFT JOINs to fetch message data, causing performance overheadMessageDetailsView, making the view progressively larger and slower with each additionWe consolidated all 10 system message content tables into a single MessageSystemContent table using a discriminator pattern with generic typed columns.
CREATE TABLE MessageSystemContent (
message_id TEXT NOT NULL,
conversation_id TEXT NOT NULL,
content_type TEXT NOT NULL, -- Discriminator: 'MEMBER_CHANGE', 'FAILED_DECRYPT', etc.
-- Generic typed fields for flexible storage
text_1 TEXT, -- conversation_name, protocol, etc.
integer_1 INTEGER, -- message_timer, error_code, etc.
boolean_1 INTEGER, -- receipt_mode, is_apps_enabled, is_decryption_resolved
list_1 TEXT, -- member lists, domain lists
enum_1 TEXT, -- member_change_type, federation_type, legal_hold_type
blob_1 BLOB, -- unknown_encoded_data for failed decryption
FOREIGN KEY (message_id, conversation_id) REFERENCES Message(id, conversation_id),
PRIMARY KEY (message_id, conversation_id)
);
CREATE INDEX idx_system_content_type ON MessageSystemContent(content_type);
Each content type maps its specific fields to the generic columns:
| Content Type | Field Mapping | |--------------|---------------| | MEMBER_CHANGE | list_1=members, enum_1=type | | FAILED_DECRYPT | blob_1=data, boolean_1=resolved, integer_1=error_code | | CONVERSATION_RENAMED | text_1=name | | RECEIPT_MODE | boolean_1=enabled | | TIMER_CHANGED | integer_1=duration_ms | | FEDERATION_TERMINATED | list_1=domains, enum_1=type | | PROTOCOL_CHANGED | text_1=protocol | | LEGAL_HOLD | list_1=members, enum_1=type | | APPS_ENABLED | boolean_1=enabled |
The MessageDetailsView uses CASE statements to maintain semantic column names, ensuring no breaking changes to application code:
CASE WHEN SystemContent.content_type = 'MEMBER_CHANGE'
THEN SystemContent.list_1 END AS memberChangeList,
CASE WHEN SystemContent.content_type = 'MEMBER_CHANGE'
THEN SystemContent.enum_1 END AS memberChangeType,
This allows existing Kotlin code to continue working without modifications:
val message = messagesQueries.selectById(messageId, conversationId).executeAsOne()
val members = message.memberChangeList // Still works!
val changeType = message.memberChangeType // Still works!
The migration (120.sqm) performs these steps:
MessageSystemContent tableSchemaMigrationTest pattern for future migrationsinsertSystemMemberChange) hide generic field names from developersMessageDetailsView provides semantic column names, maintaining code readabilityWhen adding a new system message type:
content_type discriminator value (e.g., 'NEW_MESSAGE_TYPE')Messages.sqMessageDetailsView with CASE statements for the new type (requires migration)MessageInsertExtensionImpl.kt to use the new queryAlways query through MessageDetailsView to get semantic column names:
// Good - uses view with semantic names
val message = messagesQueries.selectById(messageId, conversationId).executeAsOne()
val name = message.conversationName
// Avoid - direct table access with generic names
// This makes code harder to understand
persistence/src/commonMain/db_user/migrations/120.sqmDate: 2025-11-18
Accepted
Database migrations in Kalium are critical operations that modify both schema structure and transform existing user data. Previously, migration testing was inconsistent and ad-hoc, with most migrations only tested through integration tests that ran the entire migration chain. This approach had several problems:
The consolidation of 10 system message content tables into a single table (Migration 120) highlighted the need for a robust testing framework that could:
We created the SchemaMigrationTest framework, a comprehensive testing infrastructure for database migrations that involve both schema changes and data transformations.
The framework consists of three main components:
SchemaMigrationTest.ktA base class that provides:
abstract class SchemaMigrationTest {
protected fun runMigrationTest(
schemaVersion: Int,
setupOldSchema: (SqlDriver) -> Unit,
migrationSql: () -> String,
verifyNewSchema: (SqlDriver) -> Unit
)
}
Key capabilities:
.db files stored in src/commonTest/kotlin/com/wire/kalium/persistence/schemas/Helper methods:
executeInsert(sql) - Insert data with simple SQLcountRows(tableName) - Get row count from a tablequerySingleValue(sql, mapper) - Query and extract a single valueexecuteQuery(sql, mapper) - General-purpose query executionPre-migration database schemas stored as SQLite .db files:
persistence/src/commonTest/kotlin/com/wire/kalium/persistence/schemas/
├── 124.db
└── ...
These files are snapshots of the actual schema before a migration runs, generated using:
./gradlew :persistence:generateCommonMainUserDatabaseInterface
cp persistence/src/commonMain/db_user/schemas/124.db \
persistence/src/commonTest/kotlin/com/wire/kalium/persistence/schemas/124.db
Individual test classes that extend SchemaMigrationTest and test specific migrations:
class Migration120Test : SchemaMigrationTest() {
@Test
fun testMemberChangeContentMigration() = runTest(dispatcher) {
runMigrationTest(
schemaVersion = 120,
setupOldSchema = { driver ->
// Insert test data into old schema
driver.executeInsert("""
INSERT INTO MessageMemberChangeContent
(message_id, conversation_id, member_change_list, member_change_type)
VALUES ('msg-1', 'conv-1', '["user1", "user2"]', 'ADDED')
""")
},
migrationSql = {
// Read actual migration SQL from file
File("src/commonMain/db_user/migrations/120.sqm").readText()
},
verifyNewSchema = { driver ->
// Verify the migrated data
val count = driver.countRows("MessageSystemContent")
assertEquals(1, count)
var contentType: String? = null
var list1: String? = null
driver.executeQuery(null, """
SELECT content_type, list_1
FROM MessageSystemContent
WHERE message_id = 'msg-1'
""".trimIndent(), { cursor ->
if (cursor.next().value) {
contentType = cursor.getString(0)
list1 = cursor.getString(1)
}
QueryResult.Unit
}, 0)
assertEquals("MEMBER_CHANGE", contentType)
assertEquals("""["user1", "user2"]""", list1)
// Verify old table was dropped
assertTableDoesNotExist(driver, "MessageMemberChangeContent")
}
)
}
}
We categorize migrations into two types, each with different testing approaches:
Migrations that only modify database structure without transforming existing data.
Example: Adding a new column with a default value
Testing approach: Use simpler unit tests (like EventMigration109Test style)
When to use:
Migrations that both modify database structure AND transform existing data.
Example: Consolidating multiple tables into one (Migration 120)
Testing approach: Use SchemaMigrationTest framework (this ADR)
When to use:
For Schema + Content migrations, each test class should include:
Example from Migration120Test:
The framework supports all SQLite data types:
TEXT:
driver.executeInsert("""
INSERT INTO Table (id, name)
VALUES ('id-1', 'Test Name')
""")
INTEGER (including booleans as 0/1):
driver.executeInsert("""
INSERT INTO Table (id, is_enabled)
VALUES ('id-1', 1) -- true
""")
BLOB:
val testData = byteArrayOf(0x01, 0x02, 0x03)
driver.execute(null, """
INSERT INTO Table (id, data) VALUES (?, ?)
""".trimIndent(), 2) {
bindString(0, "id-1")
bindBytes(1, testData)
}
Reading BLOB data:
var blob: ByteArray? = null
driver.executeQuery(null, "SELECT data FROM Table WHERE id = 'id-1'", { cursor ->
if (cursor.next().value == true) {
blob = cursor.getBytes(0)
}
}, 0)
.db files must be committed to the repository (typically 50-100 KB each)When writing migration tests:
Export schema files BEFORE running the migration
./gradlew :persistence:generateCommonMainUserDatabaseInterface
cp persistence/src/commonMain/db_user/schemas/124.db \
persistence/src/commonTest/kotlin/com/wire/kalium/persistence/schemas/124.db
Read migration SQL from actual .sqm files
private fun getMigration120Sql(): String {
return File("src/commonMain/db_user/migrations/120.sqm").readText()
}
This ensures tests always use the real migration SQL
Test each content type separately
@Test fun testMemberChangeContentMigration()
@Test fun testFailedToDecryptContentMigration()
@Test fun testConversationRenamedContentMigration()
Verify ALL fields, not just the happy path
// Verify used fields have correct values
assertEquals("MEMBER_CHANGE", contentType)
assertEquals("""["user1", "user2"]""", list1)
// Verify unused fields are NULL
assertNull(text1)
assertNull(integer1)
assertNull(blob1)
Always verify old tables are dropped
assertTableDoesNotExist(driver, "MessageMemberChangeContent")
Test with realistic data
Use descriptive test names
@Test fun testMemberChangeDataIntegrity_AllFieldsPreserved()
@Test fun testAllSystemMessageTypesNoDataLoss()
# Run all migration tests for migration 120
./gradlew :persistence:jvmTest --tests "*Migration120Test"
# Run a specific test
./gradlew :persistence:jvmTest --tests "*Migration120Test.testMemberChangeContentMigration"
# Run all schema migration tests
./gradlew :persistence:jvmTest --tests "*migration*"
Step 1: Export the schema file BEFORE applying the migration
./gradlew :persistence:generateCommonMainUserDatabaseInterface
cp persistence/src/commonMain/db_user/schemas/121.db \
persistence/src/commonTest/kotlin/com/wire/kalium/persistence/schemas/121.db
Step 2: Create the test class
package com.wire.kalium.persistence.dao.migration
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
class Migration121Test : SchemaMigrationTest() {
companion object {
private const val MESSAGE_ID = "test-message-id"
private const val CONVERSATION_ID = "test-conversation-id"
private const val MIGRATION_NAME = 121
}
private fun getMigration121Sql(): String {
val migrationFile = File("src/commonMain/db_user/migrations/$MIGRATION_NAME.sqm")
if (!migrationFile.exists()) {
error("Migration file not found: ${migrationFile.absolutePath}")
}
return migrationFile.readText()
}
@Test
fun testMyDataMigration() = runTest(dispatcher) {
runMigrationTest(
schemaVersion = MIGRATION_NAME,
setupOldSchema = { driver ->
// Insert test data into old schema
driver.executeInsert("""
INSERT INTO OldTable (id, value)
VALUES ('$MESSAGE_ID', 'test-value')
""")
},
migrationSql = { getMigration121Sql() },
verifyNewSchema = { driver ->
// Verify the migrated data
val count = driver.countRows("NewTable")
assertEquals(1, count)
var migratedValue: String? = null
driver.executeQuery(null, """
SELECT value FROM NewTable WHERE id = '$MESSAGE_ID'
""".trimIndent(), { cursor ->
if (cursor.next().value) {
migratedValue = cursor.getString(0)
}
QueryResult.Unit
}, 0)
assertEquals("test-value", migratedValue)
// Verify old table was dropped
assertTableDoesNotExist(driver, "OldTable")
}
)
}
}
Step 3: Run the test
./gradlew :persistence:jvmTest --tests "*Migration121Test"
Migration 120 consolidated 10 separate system message content tables into a single MessageSystemContent table. The test suite demonstrates comprehensive coverage with the following test types:
Each old table gets its own test to verify correct migration:
@Test
fun testMemberChangeContentMigration() = runTest(dispatcher) {
runMigrationTest(
schemaVersion = 120,
setupOldSchema = { driver ->
driver.executeInsert("""
INSERT INTO MessageMemberChangeContent
(message_id, conversation_id, member_change_list, member_change_type)
VALUES ('$MESSAGE_ID', '$CONVERSATION_ID', '["user1", "user2"]', 'ADDED')
""")
},
migrationSql = { getMigration120Sql() },
verifyNewSchema = { driver ->
val count = driver.countRows("MessageSystemContent")
assertEquals(1, count)
var contentType: String? = null
var list1: String? = null
var enum1: String? = null
driver.executeQuery(null, """
SELECT content_type, list_1, enum_1
FROM MessageSystemContent
WHERE message_id = '$MESSAGE_ID'
""".trimIndent(), { cursor ->
if (cursor.next().value) {
contentType = cursor.getString(0)
list1 = cursor.getString(1)
enum1 = cursor.getString(2)
}
QueryResult.Unit
}, 0)
assertEquals("MEMBER_CHANGE", contentType)
assertEquals("""["user1", "user2"]""", list1)
assertEquals("ADDED", enum1)
assertTableDoesNotExist(driver, "MessageMemberChangeContent")
}
)
}
Similar tests cover:
testFailedToDecryptContentMigration - Tests BLOB and error code migrationtestConversationRenamedContentMigration - Tests text field migrationtestReceiptModeContentMigration - Tests boolean field migration from two separate tablestestTimerChangedContentMigration - Tests integer field migrationtestFederationTerminatedContentMigration - Tests list and enum migrationtestProtocolChangedContentMigration - Tests enum-only migrationtestLegalHoldContentMigration - Tests complex list with enumstestAppsEnabledChangedContentMigration - Tests boolean field migrationtestMultipleSystemMessagesMigration - Tests migrating all types togethertestIndexCreation - Verifies indexes are created correctlyVerify ALL fields are correctly migrated, including NULL values for unused fields:
@Test
fun testMemberChangeDataIntegrity_AllFieldsPreserved() = runTest(dispatcher) {
runMigrationTest(
schemaVersion = 120,
setupOldSchema = { driver ->
driver.executeInsert("""
INSERT INTO MessageMemberChangeContent
(message_id, conversation_id, member_change_list, member_change_type)
VALUES ('$MESSAGE_ID', '$CONVERSATION_ID',
'["user1", "user2"]', 'FEDERATION_REMOVED')
""")
},
migrationSql = { getMigration120Sql() },
verifyNewSchema = { driver ->
var messageId: String? = null
var conversationId: String? = null
var contentType: String? = null
var text1: String? = null
var integer1: Long? = null
var boolean1: Long? = null
var list1: String? = null
var enum1: String? = null
var blob1: ByteArray? = null
driver.executeQuery(null, """
SELECT message_id, conversation_id, content_type,
text_1, integer_1, boolean_1, list_1, enum_1, blob_1
FROM MessageSystemContent
WHERE message_id = '$MESSAGE_ID'
""".trimIndent(), { cursor ->
if (cursor.next().value) {
messageId = cursor.getString(0)
conversationId = cursor.getString(1)
contentType = cursor.getString(2)
text1 = cursor.getString(3)
integer1 = cursor.getLong(4)
boolean1 = cursor.getLong(5)
list1 = cursor.getString(6)
enum1 = cursor.getString(7)
blob1 = cursor.getBytes(8)
}
QueryResult.Unit
}, 0)
// Verify PKs
assertEquals(MESSAGE_ID, messageId)
assertEquals(CONVERSATION_ID, conversationId)
// Verify content type
assertEquals("MEMBER_CHANGE", contentType)
// Verify used fields
assertEquals("""["user1", "user2"]""", list1)
assertEquals("FEDERATION_REMOVED", enum1)
// Verify unused fields are NULL
assertNull(text1)
assertNull(integer1)
assertNull(boolean1)
assertNull(blob1)
}
)
}
Similar comprehensive tests for:
testFailedDecryptDataIntegrity_AllFieldsPreservedtestConversationRenamedDataIntegrity_AllFieldsPreservedtestAllReceiptModeTypesDataIntegritytestFederationTerminatedDataIntegrity_ComplexListPreservedtestLegalHoldDataIntegrity_ComplexMemberListtestAllSystemMessageTypesNoDataLoss - Tests all 10 types together with full field verificationTotal: 19+ test cases covering every migration scenario, edge case, and data integrity requirement.
Error: Schema file not found: /com/wire/kalium/persistence/schemas/119.db
Solution: Make sure the schema file exists at:
persistence/src/commonTest/kotlin/com/wire/kalium/persistence/schemas/124.db
Generate it with:
./gradlew :persistence:generateCommonMainUserDatabaseInterface
cp persistence/src/commonMain/db_user/schemas/124.db \
persistence/src/commonTest/kotlin/com/wire/kalium/persistence/schemas/124.db
Error: Failed to execute migration statement: ...
Solution:
Error: table MessageSystemContent already exists
Solution: Make sure your migration SQL uses CREATE TABLE IF NOT EXISTS or the schema file is from before the migration. The schema file should be generated BEFORE you write the migration SQL.
// Wrong - string binding for BLOB
driver.execute(null, "INSERT INTO Table (data) VALUES (?)", 1) {
bindString(0, testData.toString()) // Wrong!
}
// Correct - bytes binding for BLOB
driver.execute(null, "INSERT INTO Table (data) VALUES (?)", 1) {
bindBytes(0, testData) // Correct!
}
SQLite doesn't have a native boolean type. Use INTEGER with 0/1:
// Insert
driver.executeInsert("INSERT INTO Table (is_enabled) VALUES (1)") // true
// Query - returns Long, not Boolean
val isEnabled: Long? = cursor.getLong(0) // 0 or 1
assertEquals(1L, isEnabled) // true
Potential enhancements to the framework:
persistence/src/jvmTest/kotlin/com/wire/kalium/persistence/dao/migration/Migration120Test.kt - Reference implementation with 19+ test casespersistence/src/jvmTest/kotlin/com/wire/kalium/persistence/dao/migration/SchemaMigrationTest.kt - Framework implementationpersistence/src/commonMain/db_user/migrations/124.sqm - Actual migration SQLDate: 2025-11-27
Accepted
The Kalium codebase had grown organically with modules at the root level (e.g., :common, :data, :persistence, :network, :backup, :calling, :cells). This flat structure made it difficult to understand module relationships, enforce architectural boundaries, and maintain clear separation of concerns. As the codebase scaled, we needed a more organized module hierarchy that would:
We reorganized modules into a hierarchical structure with clear architectural boundaries:
Core Layer (:core:*) - Foundation modules:
:core:common (was :common):core:data (was :data):core:cryptography (was :cryptography):core:logger (was :logger):core:util (was :util)Data Layer (:data:*) - Data access and infrastructure:
:data:network (was :network):data:network-model (was :network-model):data:network-util (was :network-util):data:persistence (was :persistence):data:persistence-test (was :persistence-test):data:protobuf (was :protobuf):data:data-mappers (new module for transformations)Domain Layer (:domain:*) - Business logic boundaries:
:domain:backup (was :backup):domain:calling (was :calling):domain:cells (was :cells):domain:conversation-history (new):domain:messaging:sending (new, extracted from logic):domain:messaging:receiving (new, extracted from logic)Test Layer (:test:*) - Testing utilities:
:test:mocks (was :mocks):test:data-mocks (new):test:benchmarks (was :benchmarks):test:tango-tests (was :tango-tests)Sample/Tools Layer (:sample:*, :tools:*):
:sample:cli (was :cli):sample:samples (was :samples):tools:testservice (was :testservice):tools:monkeys (was :monkeys):tools:backup-verification (new):tools:protobuf-codegen (was :protobuf-codegen)All module references were updated throughout the codebase including build files, CI workflows, documentation, and the dependency graph visualization.
Benefits:
:data:network vs just :network)Trade-offs:
projects.core.common instead of projects.common)Technical Changes:
implementation(projects.*) references in build files:cli:assemble → :sample:cli:assemble)Date: 2025-12-03
Accepted
On Android clients with a large number of conversations and heavy message history, the conversation
list was slow to load.
Profiling showed a significant delay during the COUNT phase executed before the Paging source loads
items.
The existing COUNT query used the ConversationDetails view, which performs many joins, user
metadata checks, visibility rules, and sorting-related logic.
This resulted in:
Most of this logic is needed for the conversation list itself, but not for the COUNT used by the Paging library. Paging only requires an upper bound, not the exact filtered set.
Use a lightweight COUNT that operates directly on the Conversation table, but only when:
searchQuery is empty (no text search filtering required)The new countConversations query is equivalent to the core filters used by the real paging SELECT
and matches:
archiveddeleted_locallyHowever, it intentionally does not perform the additional visibility logic from
ConversationDetails, such as:
These rules remain enforced by the actual SELECT used for loading pages.
As a result, the lightweight COUNT may return a superset of rows, but this is acceptable because the
Paging source uses the full SELECT query for real data.
The COUNT only provides a high-level upper bound.
Below is the lightweight COUNT query introduced:
SELECT COUNT(*)
FROM Conversation
WHERE
type IS NOT 'SELF'
AND CASE
WHEN :conversationFilter = 'ALL' THEN 1 = 1
WHEN :conversationFilter = 'GROUPS' THEN (type = 'GROUP' AND is_channel = 0)
WHEN :conversationFilter = 'ONE_ON_ONE' THEN type = 'ONE_ON_ONE'
WHEN :conversationFilter = 'CHANNELS' THEN (type = 'GROUP' AND is_channel = 1)
ELSE 1 = 0
END
AND archived = :fromArchive
AND deleted_locally = 0
AND (
protocol IN ('PROTEUS','MIXED')
OR (
protocol = 'MLS'
AND (
:strict_mls = 0
OR mls_group_state IN ('ESTABLISHED','PENDING_AFTER_RESET')
)
)
);
Date: 2025-12-23
Accepted
The Kalium logic module serves as the main SDK entry point that client applications interact with. Without explicit visibility modifiers, it was difficult to distinguish between public API surface (intended for consumers) and internal implementation details. This led to several issues:
CryptoTransactionProvider were being directly accessed by sample applications, bypassing proper API abstractionsKotlin's explicitApi() mode enforces that all public declarations have explicit visibility modifiers and return types, forcing intentional decisions about API surface.
We enabled explicitApi() mode for the :logic module and adopted an internal-first migration strategy:
internal visibility modifiers to all declarations lacking explicit visibility (~3,449 declarations)internal modifiers from interface members (1,278 instances), as they inherit visibility from the interfacepublic when consumer modules failed to compile, ensuring minimal API surfacecryptoTransactionProvider), created public wrapper methods in appropriate scopes (e.g., DebugScope.refillKeyPackages(), DebugScope.generateEvents())Created Python scripts for automated migration to handle:
internal modifiers while skipping override functionsinternal from interface membersUsed @InternalKaliumApi opt-in annotation for:
CryptoTransactionProvider are now properly encapsulated behind public wrappersCosts:
For consumers experiencing compilation errors:
@OptIn(InternalKaliumApi::class) if the internal API is intentionally marked with @InternalKaliumApi