ADR Viewer - kalium

Decision record template by Michael Nygard

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:

Title

Status

What is the status, such as proposed, accepted, rejected, deprecated, superseded, etc.?

Context

What is the issue that we're seeing that is motivating this decision or change?

Decision

What is the change that we're proposing and/or doing?

Consequences

What becomes easier or more difficult to do because of this change?

1. Record architecture decisions

Date: 2025-12-03

Status

Accepted

Context

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.

Decision

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.

Consequences

  • We need to add a new folder to the repository, docs/adr, to keep the architecture decision records.
  • Whenever a significant architecture decision is made, we need to create a new ADR file in that folder.
  • We need to review the ADRs periodically to ensure they are still relevant and up-to-date.
  • This will help us maintain a clear record of our architecture decisions and the reasoning behind them, which will be useful for future reference and onboarding new team members.

2. Consolidate System Message Content Tables

Date: 2025-11-18

Status

Accepted

Context

The Kalium persistence layer previously used 10 separate tables to store different types of system message content:

  1. MessageMemberChangeContent
  2. MessageFailedToDecryptContent
  3. MessageConversationChangedContent
  4. MessageNewConversationReceiptModeContent
  5. MessageConversationReceiptModeChangedContent
  6. MessageConversationTimerChangedContent
  7. MessageFederationTerminatedContent
  8. MessageConversationProtocolChangedContent
  9. MessageLegalHoldContent
  10. MessageConversationAppsEnabledChangedContent

This design led to several issues:

  • Query complexity: The MessageDetailsView required a ton LEFT JOINs to fetch message data, causing performance overhead
  • Schema rigidity: Adding new system message types required schema migrations, adding new tables, and updating the view
  • Unbounded view growth: Every new system message type required adding another LEFT JOIN to MessageDetailsView, making the view progressively larger and slower with each addition
  • Maintenance burden: Each new message type needed its own table definition, insert queries, update queries, and view integration
  • Query performance: Multiple JOINs across 10+ tables for every message query degraded performance, especially for large message lists

Decision

We consolidated all 10 system message content tables into a single MessageSystemContent table using a discriminator pattern with generic typed columns.

New Schema (Migration 120)

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);

Field Mapping Pattern

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 |

View Layer Abstraction

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!

Migration Strategy

The migration (120.sqm) performs these steps:

  1. Creates the new MessageSystemContent table
  2. Migrates all data from the 10 old tables with proper field mapping
  3. Drops the old tables
  4. Recreates views to use the new consolidated table

Consequences

Positive

  • Query performance: 20-40% improvement expected due to JOIN reduction (12+ JOINs → 2 JOINs)
  • Schema flexibility: New system message types can be added without schema migrations
  • Simplified maintenance: Single table to manage instead of 10 separate tables
  • Index efficiency: Single composite primary key and one content_type index instead of 10 primary keys
  • Code compatibility: No breaking changes to application code thanks to view abstraction
  • Testing framework: Established SchemaMigrationTest pattern for future migrations

Negative

  • Reduced type safety: Generic column names (text_1, integer_1) are less self-documenting than specific columns (conversation_name, message_timer)
  • Documentation dependency: Developers must consult field mapping documentation to understand which generic column maps to which semantic field
  • NULL overhead: Each row contains 6 generic columns, most of which will be NULL for any given message type
  • Database-level validation: Cannot enforce NOT NULL constraints on specific fields per content type

Mitigation Strategies

  1. Documentation: Comprehensive field mapping documentation for all content types
  2. Convenience queries: Type-specific insert queries (e.g., insertSystemMemberChange) hide generic field names from developers
  3. View abstraction: MessageDetailsView provides semantic column names, maintaining code readability
  4. Migration guide: Detailed guide for updating Kotlin code

Usage Guidelines

Adding New System Message Types

When adding a new system message type:

  1. Choose a content_type discriminator value (e.g., 'NEW_MESSAGE_TYPE')
  2. Map semantic fields to generic columns
  3. Add convenience insert query in Messages.sq
  4. Update MessageDetailsView with CASE statements for the new type (requires migration)
  5. Update MessageInsertExtensionImpl.kt to use the new query

Querying System Messages

Always 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

Related Documentation

  • Migration SQL: persistence/src/commonMain/db_user/migrations/120.sqm

3. Database Migration Testing Framework

Date: 2025-11-18

Status

Accepted

Context

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:

  • Insufficient coverage: Migrations were not tested individually with realistic data scenarios
  • Late failure detection: Migration bugs were only discovered after the migration ran on production databases
  • Data loss risk: No systematic verification that all data was correctly transformed during migrations
  • Debugging difficulty: When a migration failed, it was hard to pinpoint which specific data transformation was incorrect
  • No regression testing: Once a migration was released, we had no automated way to verify it continued working correctly

The consolidation of 10 system message content tables into a single table (Migration 120) highlighted the need for a robust testing framework that could:

  1. Load pre-migration database schemas from actual schema files
  2. Insert realistic test data into old schema structures
  3. Execute migration SQL in isolation
  4. Verify data integrity and completeness after migration
  5. Test edge cases like NULL values, complex data types, and large datasets

Decision

We created the SchemaMigrationTest framework, a comprehensive testing infrastructure for database migrations that involve both schema changes and data transformations.

Architecture

The framework consists of three main components:

1. Base Test Class: SchemaMigrationTest.kt

A base class that provides:

abstract class SchemaMigrationTest {
    protected fun runMigrationTest(
        schemaVersion: Int,
        setupOldSchema: (SqlDriver) -> Unit,
        migrationSql: () -> String,
        verifyNewSchema: (SqlDriver) -> Unit
    )
}

Key capabilities:

  • Loads pre-migration schema from .db files stored in src/commonTest/kotlin/com/wire/kalium/persistence/schemas/
  • Creates temporary test databases using JDBC SQLite driver
  • Executes multi-statement migration SQL
  • Provides helper methods for common database operations

Helper methods:

  • executeInsert(sql) - Insert data with simple SQL
  • countRows(tableName) - Get row count from a table
  • querySingleValue(sql, mapper) - Query and extract a single value
  • executeQuery(sql, mapper) - General-purpose query execution

2. Schema Files

Pre-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

3. Migration Tests

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")
            }
        )
    }
}

Testing Strategy

We categorize migrations into two types, each with different testing approaches:

Type 1: Schema-only Migrations

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:

  • Adding new tables
  • Adding columns with default values
  • Creating indexes
  • Adding constraints

Type 2: Schema + Content Migrations

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:

  • Consolidating tables
  • Transforming data formats
  • Moving data between tables
  • Complex multi-step migrations

Test Coverage Requirements

For Schema + Content migrations, each test class should include:

  1. Individual content type tests: One test per data type being migrated
  2. Data integrity tests: Verify ALL fields are correctly migrated, including NULL values
  3. Multiple messages test: Test migrating several different message types simultaneously
  4. Index creation tests: Verify indexes are created correctly
  5. Old table cleanup tests: Verify old tables are dropped
  6. Edge case tests: Empty strings, NULL values, special characters, large datasets

Example from Migration120Test:

  • 11 individual content type tests
  • 6 comprehensive data integrity tests
  • 1 multi-message migration test
  • 1 index creation test
  • Total: 19+ test cases

Working with Different Data Types

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)

Consequences

Positive

  • Early bug detection: Migration bugs are caught during development, not in production
  • Data integrity guarantee: Systematic verification ensures no data loss during migrations
  • Regression prevention: Migrations remain tested even after release
  • Realistic testing: Uses actual schema files and migration SQL, not mocks
  • Fast feedback: Tests run in milliseconds using in-memory SQLite databases
  • Documentation: Tests serve as executable documentation of migration behavior
  • Confidence: Developers can refactor migrations knowing tests will catch breaks
  • Reusable infrastructure: Base class and helpers make writing new tests straightforward

Negative

  • Manual schema management: Developers must remember to copy schema files before running migrations
  • Storage overhead: Schema .db files must be committed to the repository (typically 50-100 KB each)
  • JVM-only: Tests only run on JVM target, not on Android or iOS
  • Maintenance burden: Each migration requires significant test code (Migration120Test is ~1000 lines)
  • Slower CI builds: More comprehensive tests increase CI build time
  • Learning curve: Developers need to understand SQLite JDBC API and framework patterns

Mitigation Strategies

  1. Helper methods: Base class provides common operations to reduce boilerplate
  2. Template tests: Migration120Test serves as a template for future migration tests
  3. CI integration: Automated checks verify schema files exist before allowing PR merge
  4. Code review checklist: Ensure all migration PRs include corresponding tests

Best Practices

When writing migration tests:

  1. 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
    
  2. 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

  3. Test each content type separately

    @Test fun testMemberChangeContentMigration()
    @Test fun testFailedToDecryptContentMigration()
    @Test fun testConversationRenamedContentMigration()
    
  4. 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)
    
  5. Always verify old tables are dropped

    assertTableDoesNotExist(driver, "MessageMemberChangeContent")
    
  6. Test with realistic data

    • Use actual user IDs, conversation IDs
    • Include special characters, emojis
    • Test edge cases: empty strings, NULL values, large datasets
  7. Use descriptive test names

    @Test fun testMemberChangeDataIntegrity_AllFieldsPreserved()
    @Test fun testAllSystemMessageTypesNoDataLoss()
    

Running the Tests

# 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*"

Example: Creating a New Migration Test

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"

Complete Example: Migration 120 - System Message Consolidation

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:

Basic Migration Tests (11 tests)

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 migration
  • testConversationRenamedContentMigration - Tests text field migration
  • testReceiptModeContentMigration - Tests boolean field migration from two separate tables
  • testTimerChangedContentMigration - Tests integer field migration
  • testFederationTerminatedContentMigration - Tests list and enum migration
  • testProtocolChangedContentMigration - Tests enum-only migration
  • testLegalHoldContentMigration - Tests complex list with enums
  • testAppsEnabledChangedContentMigration - Tests boolean field migration
  • testMultipleSystemMessagesMigration - Tests migrating all types together
  • testIndexCreation - Verifies indexes are created correctly

Data Integrity Tests (6 tests)

Verify 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_AllFieldsPreserved
  • testConversationRenamedDataIntegrity_AllFieldsPreserved
  • testAllReceiptModeTypesDataIntegrity
  • testFederationTerminatedDataIntegrity_ComplexListPreserved
  • testLegalHoldDataIntegrity_ComplexMemberList
  • testAllSystemMessageTypesNoDataLoss - Tests all 10 types together with full field verification

Total: 19+ test cases covering every migration scenario, edge case, and data integrity requirement.

Troubleshooting Common Issues

Schema file not found

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

Migration SQL fails

Error: Failed to execute migration statement: ...

Solution:

  1. Check that your migration SQL is valid SQLite
  2. Ensure the old tables exist in the schema file
  3. Verify foreign key constraints are satisfied
  4. Test each SQL statement individually

Table already exists

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.

BLOB data not migrating correctly

// 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!
}

Boolean values not working

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

Future Improvements

Potential enhancements to the framework:

  1. Automatic schema file management: Git hooks or Gradle tasks to automatically copy schema files
  2. Multi-platform support: Extend tests to run on Android and iOS targets
  3. Performance testing: Measure migration execution time with large datasets
  4. Data generation helpers: Factory methods for creating realistic test data
  5. Migration comparison: Tools to diff schemas before/after migration
  6. Visual regression: Generate schema diagrams before/after migration for documentation

Related Files

  • Example Test: persistence/src/jvmTest/kotlin/com/wire/kalium/persistence/dao/migration/Migration120Test.kt - Reference implementation with 19+ test cases
  • Base Class: persistence/src/jvmTest/kotlin/com/wire/kalium/persistence/dao/migration/SchemaMigrationTest.kt - Framework implementation
  • Related ADR: ADR 0002 - Consolidate System Message Content Tables
  • Migration File: persistence/src/commonMain/db_user/migrations/124.sqm - Actual migration SQL

4. Module Boundary Restructuring

Date: 2025-11-27

Status

Accepted

Context

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:

  • Clearly define architectural layers and their responsibilities
  • Make module dependencies more explicit and easier to reason about
  • Improve discoverability by grouping related modules together
  • Enforce better separation between core infrastructure, data access, domain logic, and testing utilities

Decision

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:link-preview-fetch (isolated transport for untrusted link preview URLs)
  • :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.

Consequences

Benefits:

  • Clearer Architecture: The layer-based structure makes the architecture immediately visible from the project structure
  • Better Discoverability: Developers can quickly locate modules by their architectural purpose
  • Enforced Boundaries: The naming scheme makes it obvious when a module is reaching across layers inappropriately
  • Improved Documentation: Module paths now self-document their architectural role (e.g., :data:network vs just :network)
  • Scalability: New modules can be added to appropriate layers without cluttering the root
  • Easier Onboarding: New team members can understand the system organization more quickly

Trade-offs:

  • Migration Effort: All module references needed updating across build files, CI workflows, and documentation
  • Longer Module Paths: Module references are now more verbose (e.g., projects.core.common instead of projects.common)
  • Breaking Change: External consumers referencing modules directly will need to update their references

Technical Changes:

  • Updated all implementation(projects.*) references in build files
  • Updated CI workflow gradle task paths (e.g., :cli:assemble → :sample:cli:assemble)
  • Updated module graph configuration to show full paths and nest by module type
  • Updated detekt baseline and project structure documentation
  • Added README files to layer directories explaining their purpose and guidelines

5. Lightweight COUNT for Faster Conversation List Loading

Date: 2025-12-03

Status

Accepted

Context

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:

  • creation of temp B-trees
  • expensive scans over large joined structures
  • noticeable TTI spikes on older devices and devices with large databases

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.

Decision

Use a lightweight COUNT that operates directly on the Conversation table, but only when:

  • searchQuery is empty (no text search filtering required)
  • no metadata-dependent visibility logic is needed for COUNT

The new countConversations query is equivalent to the core filters used by the real paging SELECT and matches:

  • exclude conversations of type SELF
  • apply conversationFilter (ALL, GROUPS, ONE_ON_ONE, CHANNELS)
  • apply archived
  • apply deleted_locally
  • apply protocol and MLS state (strict MLS, ESTABLISHED, PENDING_AFTER_RESET)

However, it intentionally does not perform the additional visibility logic from ConversationDetails, such as:

  • 1:1 metadata checks (missing name, missing otherUserId)
  • userDeleted or defederated logic
  • CONNECTION_PENDING visibility rules
  • isActive calculation from Member/User tables
  • interactionEnabled filtering
  • favorites and folder membership filtering

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.

Consequences

Benefits

  • Significant reduction of TTI spikes on conversation list screen
  • COUNT no longer triggers expensive joins or temporary B-trees
  • More stable performance on large accounts and older hardware
  • No functional changes to conversation visibility or ordering
  • Paging logic remains correct because only the real SELECT determines item membership

Trade-offs

  • COUNT may return a slightly higher number than the actual number of displayed conversations
  • Search queries still use the full COUNT because search relies on fields only available in the ConversationDetails view

Technical Notes

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')
            )
        )
    );

6. Explicit API Mode Migration for Logic Module

Date: 2025-12-23

Status

Accepted

Context

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:

  1. Accidental API Exposure: Internal implementation classes and functions were inadvertently exposed to consumers, creating unintended API contracts
  2. API Maintenance Burden: Changes to internal implementations could break consumers who were using APIs that were never intended to be public
  3. Unclear API Boundaries: Developers couldn't easily distinguish between stable public APIs and internal utilities
  4. cryptoTransactionProvider Leakage: Critical internal components like CryptoTransactionProvider were being directly accessed by sample applications, bypassing proper API abstractions

Kotlin's explicitApi() mode enforces that all public declarations have explicit visibility modifiers and return types, forcing intentional decisions about API surface.

Decision

We enabled explicitApi() mode for the :logic module and adopted an internal-first migration strategy:

  1. Mark Everything as Internal: Used automated scripts to add internal visibility modifiers to all declarations lacking explicit visibility (~3,449 declarations)
  2. Fix Interface Members: Removed incorrectly added internal modifiers from interface members (1,278 instances), as they inherit visibility from the interface
  3. Selective Public Exposure: Only made APIs public when consumer modules failed to compile, ensuring minimal API surface
  4. Create Public Wrappers: For internal components that needed controlled access (like cryptoTransactionProvider), created public wrapper methods in appropriate scopes (e.g., DebugScope.refillKeyPackages(), DebugScope.generateEvents())

Implementation Details

  • Created Python scripts for automated migration to handle:

    • Adding internal modifiers while skipping override functions
    • Removing invalid internal from interface members
    • Preserving proper indentation and code structure
  • Used @InternalKaliumApi opt-in annotation for:

    • Test code accessing internal APIs within the same module
    • Debug utilities that need controlled internal access
    • Explicitly marking intentionally exposed internal APIs

Consequences

  • Minimal Public API: Only APIs that are actually used by consumers are public
  • Clear API Boundaries: Developers can immediately distinguish public SDK APIs from internal implementation details
  • Prevented Leakage: Critical internal components like CryptoTransactionProvider are now properly encapsulated behind public wrappers
  • Better Encapsulation: Internal implementations can be refactored without breaking consumers
  • Improved Documentation: The public API surface is now clearly defined and easier to document
  • Compiler-Enforced Contracts: Future additions must explicitly declare visibility, preventing accidental API exposure

Costs:

  • Verbose Code: All declarations now require explicit visibility modifiers

Migration Notes

For consumers experiencing compilation errors:

  1. Check if the API should be public (common use case) - submit an issue/PR to expose it
  2. For sample/test code, use @OptIn(InternalKaliumApi::class) if the internal API is intentionally marked with @InternalKaliumApi

7. Swift-Friendly Result Types for Public APIs

Date: 2026-01-16

Status

Accepted

Context

Kalium's public APIs extensively used Either<CoreFailure, Unit> for error handling. This created poor Swift interop:

  • Generic Either types translate poorly to Swift/OjbC, resulting in complex, unidiomatic APIs
  • Swift developers couldn't easily pattern match or understand error cases

Decision

Replace Either<CoreFailure, Unit> with non-generic, action-specific sealed classes for public APIs:

public sealed class MessageOperationResult {
    public data object Success : MessageOperationResult()
    public data class Failure(val error: CoreFailure) : MessageOperationResult()

    @OptIn(ExperimentalObjCRefinement::class)
    @HiddenFromObjC
    @Suppress("konsist.kaliumLogicModuleShouldNotExposeEitherTypesInPublicAPI")
    public fun toEither(): Either<CoreFailure, Unit> = ...
}

Key principles:

  • No generics (concrete types per domain)
  • Action-specific naming (MessageOperationResult, FetchConversationResult)
  • Hidden toEither() bridge function for internal Kotlin code, for backward compatibility with Android and internals. Specifically for MessageOperationResult, covering most of our Message sending APIs.
  • Use @HiddenFromObjC to avoid exposing the bridge function to Objective-C/Swift consumers.

Consequences

Easier:

  • Idiomatic Swift API with clean enum pattern matching
  • Better type safety and discoverability for Swift consumers
  • Gradual migration without breaking internal code

More difficult:

  • More boilerplate (one result type per domain vs single generic)
  • Need .toEither() bridge calls in internal code, use as needed.
  • More types to maintain

Future pattern:

  • Create specific result types (avoid generics)
  • Use action-specific names
  • Case by case, include hidden toEither() for backward compatibility
  • Annotate with @HiddenFromObjC to avoid exposing to Swift/ObjC

8. androidx.benchmark for on-device DAO benchmarks

Date: 2026-04-23

Status

Accepted

Context

Kalium currently has two complementary benchmark surfaces for the persistence layer and they leave a gap that matters in practice:

  1. :test:benchmarks — JMH-based JVM benchmarks. These run on the JVM with userDatabaseBuilder(..., passphrase = null, ...), i.e. plain SQLite. They give us a JVM baseline but they do not exercise the SQLCipher code path that production Android clients actually hit.

  2. An @Ignored message DAO benchmark test in :data:persistence commonTest. On Android it does go through the encrypted path (BaseDatabaseTest.encryptedDBSecret → SupportOpenHelperFactory → libsqlcipher), but it measures with measureTime { ... } and lives alongside correctness tests. There is no warmup, no iteration control, no output stability check, and no JSON report. It is effectively a developer-only affordance you have to un-ignore by hand.

The question we cannot answer today is: what is the real cost of message insert/select on a device with production-equivalent encryption enabled? We have a regression-prone piece of code (MessageDAO on SQLCipher) and no repeatable measurement for it.

Decision

Introduce androidx.benchmark (microbenchmark, JUnit4 variant) as the measurement framework for on-device DAO benchmarks, and add a dedicated module :test:android-benchmarks to host those benchmarks. Message scenarios run in both runners from the same fixtures, so we have a direct JVM-plain vs Android-encrypted comparison.

Key shape:

  • New module :test:android-benchmarks, a plain com.android.library module (not KMP). Our existing kalium.library convention plugin is KMP-Android, which isn't a great fit for androidx.benchmark today; keeping this module classic keeps the benchmark plugin happy and confines the non-standard build settings.

  • Release-only instrumented tests: testBuildType = "release", debuggable = false, minifyEnabled = false. This is a hard requirement of androidx.benchmark — debuggable builds are refused (or produce meaningless numbers).

  • Runner: androidx.benchmark.junit4.AndroidBenchmarkRunner (replaces the default AndroidJUnitRunner for this module only).

  • Encryption path: benchmarks build the user DB via userDatabaseBuilder with a fixed raw UserDBSecret. libsqlcipher is loaded and SupportOpenHelperFactory drives the connection — this is the production path.

  • Storage: the production database location from PlatformDatabaseData(context). BenchmarkDbRule deletes it before each case and nukes it afterwards to keep runs clean.

  • Scenarios: message insert, paging, mark-read, asset status and concurrent read/write, the conversation list, and message timelines. The module README lists them. Message data comes from test/benchmark-fixtures, which both runners compile.

  • Dependencies added (to gradle/libs.versions.toml):

    • the androidx-benchmark version key, on the 1.5 line: 1.4.x targets the old AGP TestedExtension DSL and is incompatible with AGP 9.0
    • androidx.benchmark:benchmark-junit4 library
    • androidx.benchmark Gradle plugin
    • androidx.test.ext:junit (adds AndroidJUnit4 runner; not previously in the catalog)
  • Run command:

    ./gradlew :test:android-benchmarks:connectedReleaseAndroidTest
    

    Results land in build/outputs/connected_android_test_additional_output/.../benchmarkData.json.

  • Out of scope for this ADR: CI integration, iOS/XCTest measurement, and folding into :test:benchmarks as a KMP target. Any of those come as separate ADRs once this module proves its worth.

Module dependency direction: :test:android-benchmarks depends on :data:persistence. It is a leaf test module — nothing depends on it. It does not violate the existing layering rules.

Consequences

Easier:

  • Repeatable on-device measurement of the real SQLCipher path for message insert/select, with proper warmup, iteration control, and JSON output.
  • A direct JVM-plain (JMH) ↔ Android-encrypted (androidx.benchmark) comparison for the shared message scenarios, which previously required reading two different test files and squinting.
  • A home for future on-device DAO benchmarks that doesn't pollute :data:persistence's instrumented test run (which is meant for correctness and should stay debug-build).

More difficult:

  • Two benchmark frameworks in the repo (org.jetbrains.kotlinx.benchmark for JMH, androidx.benchmark for on-device). Developers need to know which one to use for which surface. Guidance: JVM-level → JMH; Android-device → androidx.benchmark.
  • New module means new build.gradle.kts to maintain, and libsqlcipher/libsodium packaging rules have to be mirrored here.
  • Requires a physical device or emulator with stable CPU governor to produce usable numbers; this is not something we'll run in default CI without more work.
  • One more dependency to keep updated.

9. Public API, ABI, and Changelog Governance

Date: 2026-06-15

Status

Accepted

Context

Kalium is consumed as an SDK by external Android, JVM, iOS, and multiplatform clients. Those consumers need two guarantees when upgrading:

  1. Existing compiled applications should keep running when a release claims binary compatibility.
  2. Source-level API changes should be documented clearly enough that consumers know what changed, what they need to migrate, and whether the change is safe to adopt immediately.

ADR 6 already introduced explicitApi() for :logic so public declarations are intentional. However, explicit API mode does not detect whether a public declaration changed in a binary incompatible way after it was published. A change can be source-compatible but still break already compiled Kotlin/JVM consumers, for example by changing a return type, changing constructor parameters on a public data class, or adding default parameters to a public function.

Kalium also has a tag-triggered generate-changelog.yml workflow that generates release notes from commits. That is useful for release automation, but it does not force pull requests that change the consumer-facing API to include migration guidance. Release notes generated only from commit messages are not enough for SDK consumers.

Kotlin Gradle Plugin 2.2.0 and newer includes built-in ABI validation through kotlin { abiValidation() }, with checkKotlinAbi and updateKotlinAbi Gradle tasks. Kalium is on Kotlin 2.3.0, so this can be adopted without adding the older org.jetbrains.kotlinx.binary-compatibility-validator plugin.

Decision

Adopt a public API and ABI governance workflow for published Kalium artifacts.

Compatibility Policy

Kalium will distinguish between:

  • Binary compatibility: already compiled consumers can replace the previous Kalium artifact with the new one without recompiling.
  • Source compatibility: consumers can recompile their source code against the new Kalium version without changing their code.
  • Behavioral compatibility: existing API keeps the same documented semantics, except for bug fixes or explicitly documented behavior changes.

Stable public APIs should preserve binary and source compatibility within the supported release line. Breaking changes should go through a deprecation cycle where possible, and should be reserved for major releases or release lines that explicitly allow breaking changes.

APIs annotated with Kalium opt-in markers such as @InternalKaliumApi, @DebugKaliumApi, and @DelicateKaliumApi have the stability guarantees described by those annotations and their KDoc. These annotations must be used deliberately, not as a way to avoid documenting consumer-impacting changes.

ABI Validation

Use Kotlin Gradle Plugin's built-in ABI validation instead of adding the standalone binary compatibility validator plugin.

The initial rollout should cover the published artifact surface, starting with :logic because it is the main SDK entry point and already uses explicitApi(). Additional published modules should be added as their public surface is classified.

Expected Gradle shape:

kotlin {
    abiValidation {
        enabled.set(true)
        filters {
            exclude {
                annotatedWith.add("com.wire.kalium.util.InternalKaliumApi")
                annotatedWith.add("com.wire.kalium.util.DebugKaliumApi")
            }
        }
    }
}

If the Kotlin Gradle Plugin version still requires the experimental opt-in for this DSL, the module build file should opt in to org.jetbrains.kotlin.gradle.dsl.abi.ExperimentalAbiValidation.

CI must run:

./gradlew checkKotlinAbi

For the initial :logic-only rollout, CI runs the scoped task:

./gradlew :logic:checkKotlinAbi

The unqualified root task should be used once additional KMP modules have been classified and their ABI tasks can run without compiling unsupported or unpublished targets.

Developers must run this locally before finishing public API changes. If the ABI change is intentional and accepted, update the checked-in ABI dumps with:

./gradlew updateKotlinAbi

For the initial :logic-only rollout, update the checked-in ABI dumps with:

./gradlew :logic:updateKotlinAbi

ABI dump updates are reviewable API changes. Reviewers should treat them like source changes, not generated noise.

Changelog Enforcement

Any pull request that changes public API or ABI must include consumer-facing release notes.

CI should enforce this by requiring one of the following when public ABI dumps change, or when the pull request is labelled as an API-impacting change:

  • A changelog fragment was added or updated.
  • The pull request has an explicit no-changelog-needed label with a reviewer-accepted reason.
  • The pull request has an explicit internal-only label and the ABI/API gate confirms no published consumer surface changed.

Direct edits to a single CHANGELOG.md on every pull request should be avoided because they cause frequent merge conflicts. Prefer a fragment-based workflow:

changelog.d/
  1234-added-backup-status-api.md
  1235-deprecated-old-login-flow.md

Each fragment should be short and consumer-focused:

### Added
- Added `BackupStatus.InProgress` for observing backup progress.

### Migration
No action required unless consumers exhaustively match `BackupStatus`.

### Compatibility
ABI: additive.
Source: additive.
Behavior: no behavior change.

The release workflow should collect accepted fragments into release notes or CHANGELOG.md, then clear or archive consumed fragments. The existing tag-triggered generated changelog may still be used, but consumer-facing API notes must not depend only on generated commit summaries.

Pull Request Expectations

Pull requests that affect public APIs should include:

  • The ABI dump diff, when applicable.
  • A changelog fragment or accepted skip label.
  • Migration guidance for deprecations, replacements, removals, and behavior changes.
  • Tests for new or changed behavior.
  • A note when an API is intentionally experimental, delicate, debug-only, or internal.

Reviewers should block public API changes that:

  • Break ABI unintentionally.
  • Add public APIs without explicit stability classification.
  • Remove or rename stable public APIs without deprecation or release-line approval.
  • Add consumer-impacting behavior changes without release notes.
  • Update ABI dumps without explaining why the change is acceptable.

Consequences

Easier:

  • CI catches accidental public ABI changes before they reach a release.
  • Reviewers can inspect API evolution through explicit ABI dump diffs.
  • Consumers get release notes that explain how to migrate, not only what commits landed.
  • The policy builds on existing Kalium practices: explicitApi(), opt-in API annotations, and generated release automation.
  • No new Gradle dependency is needed because ABI validation is provided by Kotlin Gradle Plugin.

More difficult:

  • Public API changes require one more artifact to review: the ABI dump.
  • Intentional API changes require changelog fragments, even when the code change is small.
  • The team must maintain a clear list of published modules covered by ABI validation.
  • Some KMP/host-target combinations may need careful ABI validation configuration so unsupported local targets do not create false confidence or false failures.

Future work:

  • Add the ABI validation Gradle configuration for :logic.
  • Decide which additional published modules are part of the external API contract.
  • Add a CI workflow that runs checkKotlinAbi.
  • Add a PR changelog check that understands changelog.d/, no-changelog-needed, and internal-only.
  • Update the release workflow to merge changelog fragments into the final GitHub release notes.

References:

10. Towncrier-Native Consumer Changelog Fragments

Date: 2026-08-03

Status

Accepted

Context

ADR 9 requires consumer-facing changelog fragments for public API, ABI, and behavior changes. Its example fragment format used one Markdown file with internal ### Added, ### Migration, and ### Compatibility sections.

That format was easy to author but required custom parsing during release. The first collector concatenated fragments directly, which produced duplicated section headings. It also rendered compatibility as a detached section, making it unclear which compatibility note belonged to which change.

The existing release workflow must remain a two-step process:

  1. generate the Git changelog for the tag range
  2. prepend curated consumer-facing changelog notes

Decision

Use Towncrier to render consumer-facing changelog fragments, while keeping the generated Git changelog as the second part of the release body.

Fragments will use native Towncrier filenames:

changelog.d/1234-backup-status-api.added.md

Supported suffixes are:

  • .added.md
  • .changed.md
  • .deprecated.md
  • .removed.md
  • .fixed.md
  • .security.md

Do not use separate .compatibility.md or .migration.md types. Compatibility and migration information belongs inline under the change it describes:

Added `BackupStatus.InProgress` for observing backup progress.

  - ABI: additive
  - Source: additive
  - Behavior: no behavior change unless consumers exhaustively match `BackupStatus`.
  - Migration: no action required unless consumers exhaustively match `BackupStatus`.

The release wrapper should only select fragments for BASE_REF...HEAD_REF, run towncrier build --draft --version "$CURRENT_TAG" in a temporary directory, and prepend the output to the Git changelog. It should not parse changelog Markdown.

Towncrier is pinned in scripts/changelog-requirements.txt so Renovate can manage updates through the pip_requirements manager.

Consequences

Easier:

  • Release-note grouping is handled by Towncrier instead of custom Markdown parsing.
  • Repeated section headings are eliminated.
  • Compatibility impact stays attached to the relevant change.
  • The release body remains compatible with the existing workflow: consumer notes first, Git changelog second.
  • Renovate can update the Towncrier dependency from a standard requirements file.

More difficult:

  • A pull request may need more than one fragment file.
  • Authors must use filename suffixes instead of internal ### headings.
  • Reviewers must check that ABI/source/behavior impact is included inline when relevant.
  • Release CI now installs a small Python changelog toolchain.

This ADR supersedes only the changelog fragment format shown in ADR 9. ADR 9's API, ABI, review, and changelog requirement policy remains in force.

References

11. Account Runtime Ownership and Receiving Replay

Date: 2026-09-22

Status

Accepted

Context

On Apple platforms the app, the Notification Service Extension (NSE) and the share extension are separate processes that use the same account stores in the App Group: SQLite, CoreCrypto and the shared Keychain. Two processes must never use one account's native resources at the same time.

The NSE runs for seconds under tight memory limits. It cannot log in, register a client or migrate stores, and it must leave the app's event queue in a state the app can continue from. SQL and CoreCrypto are separate stores, so a notification's effects cannot commit atomically across both.

Decision

Account ownership

The Apple app and extensions share one exclusive per-account lease for the entire lifetime of account database, network and crypto resources. The lock is a persistent flock file outside the removable account directory, named using the versioned hash of the qualified account identity. Never unlink it during logout: other processes must keep locking the same inode. One blocked account must not prevent unrelated accounts from closing.

An extension holding the lease can be suspended with its host, so the app's wait for it stays cancellable, also when reopening after a background release, and gives up after 15 seconds with AccountBusyException; the app keeps what it shows and retries. The holder writes its process name, pid and time into the lock file body; locking never reads it, and the app logs it when it gives up waiting.

The ownership primitive lives in :domain:messaging:receiving. Existing SQLite opening stays in persistence, app session construction stays in logic, and the Swift facade lives in the iOS-only :nse module. Shared mapping and business rules remain below logic; app UI, live workers and platform integrations keep their existing owners. There is no generic opener bundle or common runtime coordinator.

Preparation and lifetime

The app awaits open; synchronous getters return only active cached sessions. Readiness is durable account state. The app revokes it before registration, credential replacement, logout and session removal, and publishes it only after successful preparation: after registration and whenever its sync reaches live. Opening, resuming and releasing an account leave it unchanged. A terminal account event found by the NSE also writes a per-client invalidation marker that keeps readiness unreadable until a different client publishes it. Successful preparation verifies the client, keys, crypto stores and supported configuration before publishing a versioned readiness sidecar; a failed check revokes it. That sidecar permits the NSE to open a prepared account without opening/migrating a global database. Tokens and keys stay in the shared Keychain configuration.

Only verified preparation publishes, so an interrupted registration stays unready until a later preparation finds the completed client. An app that dies, also before its sync is live or during a background release, leaves the last published readiness. The NSE refuses a changed client or MLS registration, an unusable cipher suite and incompatible stores, and uses the server configuration of the last preparation. A future crypto replacement must revoke readiness first and prepare only from a fresh app scope. Logout serializes mutation and cleanup against the originating session so stale work cannot reach a reopened account. Hosts must quiesce their own account operations and observers before releasing ownership. Background release keeps the app session but first stops sync and closes its WebSocket client, while no reopen can interleave; sync restarts only after the app owns the account again. Legacy push skips clients the backend serves over WebSocket, so an open socket would hide new events from the NSE. The native socket and the backend see the close asynchronously, so events in that short window may still reach the closing socket instead of APNs. A message send keeps its crypto transaction until its request ends; on a bad network that can outlast the time iOS gives the app in the background, and stopping sync can wait behind it. If the release still waits after 1 second, it fails the account's HTTP requests as offline until it starts closing. iOS has suspended the app about 4.8 seconds after it entered the background, so the release must end well before that. A failed MLS commit request counts as rejected, so CoreCrypto drops the commit even if the backend already accepted it. The client then lags the group until its next incoming message (wrong epoch, then rejoin) or its next send (stale message, then epoch check and rejoin), the same as when the process dies during that request.

NSE opening retries a busy lease for up to 3 seconds, which covers the app's background handoff, and waits for another attempt of the same process to finish: that attempt presents only what it received. If the account stays busy, the NSE asks the host for a generic alert unless the host reports that the foreground app presents alerts. Missing or incompatible readiness, such as after an app update until the app's sync is live, also asks for one unless the app presents alerts, and so does storage it cannot read. Opening then reads compatible readiness, checks existing store files and credentials, and opens owned resources. It never logs in, registers a client or initializes missing stores to repair preparation. SQLite opens without creation or migration. For an encrypted account, the NSE reads the app's existing raw database key (on iOS with its salt) from the shared Keychain; both the read-only schema probe and every pooled connection are keyed before reading pages. Missing or invalid keys never trigger generation. The iOS 32-byte readable header and WAL keep shared databases recognizable during suspension; macOS uses SQLCipher's default encrypted header. CoreCrypto's API still opens or creates/migrates its store, so readiness is a practical guard, not a stricter native contract.

Cancellation stops new work and drains resource users before network, crypto and SQLite close. Unlock only after successful cleanup. A failed native free may mark its handle destroyed before releasing the resource, so a later no-op close is not proof of cleanup. Preserve that failure and retain the runtime/lease until process exit. App live calling teardown first cancels and joins Kotlin callback work, then destroys the AVS handle and waits for its native shutdown callback before disposing the callback reference and releasing account ownership. A cancelled or incomplete shutdown retains the handle for a later close attempt. An exception during native creation leaves ownership uncertain and retains the account lease until process exit. Extension processes are reused, so an extension that retained a lease this way ends its process once it has delivered its result, and the kernel releases the lease. The NSE event-only AVS API instead retains one account/client handle and callback context for the process lifetime. Its request callbacks are attached only for the serialized start/process/end batch; this retained context owns no database, crypto, network resource or account lease. These rules prevent simultaneous native access and use-after-close.

Queue and transactions

The app and NSE share Events in insertion order. Convert app live rows to pending before NSE processing. Store each complete REST page and its fetch cursor in one SQLite transaction; only the last non-transient event advances the existing app cursor. Resume already stored pending work before fetching more. No separate queue, REST ordering table or notification acknowledgement store is added.

The app processes Events strictly in order. The NSE may defer some message-only notifications and process later independent ones, so it can acknowledge rows out of order; see conversation isolation and deferral. Any other failure fails the attempt.

For each notification, the NSE uses the app's Proteus-then-MLS nesting order:

  1. Decode payloads in backend order inside the owned native transactions.
  2. Apply required effects and pending delivery confirmations in one writer-bound SQLite transaction. Snapshot existing canonical messages before ordered writes when later clear/migration effects need them during replay.
  3. Commit native crypto, then mark the existing event processed.

Required failures throw out of the native callback; returning an error value would commit CoreCrypto. Restore the attempt's cancellation context inside its native NonCancellable wrapper, and await unwind before releasing resources. NSE attempts rethrow a callback error only after CoreCrypto confirms the abort; native abort and commit errors take precedence. The app retains its gathered-batch boundary; it does not adopt the NSE executor.

These are separate stores. SQL can commit before native crypto, and MLS can commit before Proteus. Retry decrypts again, reapplies required fields and uses canonical message IDs/native duplicate handling. MLS payloads covered by a recovery receipt are not decrypted again; covered Proteus payloads are decrypted to advance the session, but their effects are skipped. A stored system message acknowledges only its own content, not the notification or every adjacent write. Reset, recreate-after-wipe and deleted-peer guards remain where losing metadata or a native group would destroy replay inputs.

There are no notification receipts, general outcome journals or startup recovery mode. Besides the call signal journal and the account invalidation marker, narrow durable records exist only where replay would otherwise lose inputs:

  • MLS wrong-epoch recovery writes an intent before its external-join POST and payload receipts afterwards, in account Metadata; completion is recorded in the native CoreCrypto checkpoint inside the same native transaction as the join. These records load lazily and retire in the same SQL transaction as the processed marker. A join response that unexpectedly contains events is saved and refuses later NSE rejoins; nothing clears it.
  • MLS conversation deletion keeps the group identity in a deletion intent, retired with the processed marker, so retry can wipe after SQL already deleted the conversation. Local MLS removal queues a wipe that is acknowledged after native commit.
  • A conversation clear stores the exact deleted message IDs, so replay never deletes later messages. These snapshots are not pruned.

The NSE requires the prepared current schema version and never migrates SQLite.

Conversation isolation and deferral

Only the Apple NSE enables this scan. It walks every pending row in insertion order. A notification is isolatable when every payload is a Proteus or MLS new message in a known, non-self conversation. Its dependencies are those conversations, the Proteus sender sessions and the MLS groups.

An isolatable notification is deferred when, before persistence, it fails with no network, a federation failure, a 5xx response, or MLS WrongEpoch, UnmergedPendingGroup, CommitForMissingProposal, ConversationNotFound or OrphanWelcome, and every payload it decoded before the failure can cross a gap. Otherwise the failure fails the attempt. On deferral the native transactions roll back, a 30-second retry hint is stored in Metadata (nse-deferred-v1:<eventId>, removed on acknowledgement) and the scan continues. Later notifications that share a dependency with a deferred one are skipped. check/error failures, SQL errors and native commit errors never defer.

After a deferral, a later notification can be processed only when every decoded payload can cross the gap: Text, Knock, Location, Asset, Composite, Poll, Multipart, Ignored and Unknown content, or Creation, System, Profiles, DecryptionFailure, Proposal and ClearProposalTimer payloads. Any other payload, or a non-isolatable notification, is an ordering barrier and stops the scan. Results stay SUCCESS and silent:

  • DEFERRED_CONVERSATION: the target was deferred or skipped.
  • DEFERRED_ORDERING_BARRIER: the scan stopped at a barrier, or the target was already processed while other rows remain pending.
  • DEFERRED_PRESENTATION_POLICY: the target was processed after a deferral. Alert fields are cleared, AVS is not evaluated and only callSignalsPending is set.

Encrypted remote mute and in-call emoji are NSE no-ops but still pass through common decryption and replay handling, and count as conversation dependencies. Typing is skipped at DTO dispatch and adds no dependency.

Bounded incremental sync

For every push the NSE runs the app's legacy catch-up protocol (EventIngestion) before processing, like the Android app's one-shot sync. It opens the WebSocket, stores the pending REST pages fetched once it is open, then closes and joins the socket at live, so the socket is never open while the NSE processes. Pages are fetched only after the socket opens, so no event falls between a page and the socket. As in the app, catching up needs the socket: if it fails or does not reach live within the catch-up budget, the catch-up fails, and nothing is paged without it. Catch-up gets 60% of the attempt's budget; the isolated scan then processes the stored queue to its end, without fetching, and stops before the next event at 85%. Barriers and app handoffs stop the scan. Calls are evaluated only with a complete prefix. Unread messages use the unnotified-message rules below. When catch-up cannot finish, or fails or stops processing before the target with nothing to show, the result asks the host for a generic alert: unseen events may remain. There is no REST-only receiving mode.

A client registered for consumable notifications has no REST stream to page. Its catch-up consumes its own session: each event is stored before it is acknowledged, on that open session only, and the session is live when the backend confirms its marker. Events after the marker stay unacknowledged and are delivered again. A storage or acknowledgement failure ends catch-up rather than skipping the event. A missed-notification signal stops catch-up without acknowledging it, so the app's next connection receives it and recovers with slow sync; the host shows a generic alert. No Kalium caller registers this capability yet.

Proportionate replay guarantees

Required permission/member writes, sender authorization, crypto integrity and physical cleanup remain enforced. Exact access-change, promotion and connection- warning history is best effort: after SQL commit/native abort, the original no-change input may be gone and retry may add an extra system message. This does not justify blocking the entire notification and its tail.

An access event before an unknown-conversation fetch initially has no row to update. On retry it updates the now-existing row, following the app's direct field flow; access may differ from the first attempt's fetched snapshot. No prior-state journal or extra fetch is added solely to reproduce that snapshot.

Repeated text identities use existing canonical message/sender checks instead of a blanket batch rejection. Typing, remote mute and in-call reactions are transient app UI/call state; the owned NSE has no active call or live observers and ignores those signals. Unknown event types, property keys/values and feature settings follow the app's no-op policy. Required undecodable work retains an explicit unsupported boundary.

Preview metadata and quotes are durable. Optional image downloads run within the owned attempt, validate encryption inputs/hash/CBC and cache successful files. Download/image failure leaves text usable; cancellation still unwinds the attempt. Unreferenced temporary files are cleaned, and required deletion keeps stored paths until file cleanup succeeds. No preview queue or detached worker is added.

App handoffs and presentation

Use existing durable app consumers: pending deliveries, proposal timers/native pending proposals, expiration metadata, asset/Cells records, client-change rows and configuration notification flags. The NSE sends no optional proposal commit, starts no expiration timer and creates no replacement consumer. Native proposal responses must be consumed by the app's existing scheduler.

When the app must run the effect itself, the NSE stops, returns SUCCESS with appWorkPending and leaves the event and later queue in Events. The app's incremental sync runs the matching consumer before gathering:

  • ACCOUNT_INVALIDATION: a stored pending row removes this client or deletes the account. The NSE checks this before processing any row, so earlier rows stay pending too. It writes the invalidation marker and revokes readiness without deleting stores; the app runs the same offline scan and logs out with the matching reason instead of processing the queue.
  • LEGAL_HOLD: a user's legal hold is enabled or disabled, app legal-hold transitions are pending, or a membership change or fetched profile disagrees with the stored legal-hold status. The app recovers its transition journals, then processes the events normally; for enable and disable it refreshes the user's clients first. Every later attempt stops at the same event until the app runs, so a push whose event waits behind it asks for a generic alert unless the app presents alerts. The self user's legal-hold request is a single stored value, which the NSE writes as the app does.

The Apple app's pre-sync cleanup also retries pending MLS group wipes queued by local removal.

Presentation is selected from final canonical state. Actions/system messages alone are silent. Read/mute/archive/availability, verification, expiration and host privacy settings determine alert/body eligibility.

An attempt can process messages whose own pushes never present them: an earlier push found the account busy or ran out of time, APNs kept only the latest push while the device was offline, or pushes arrived out of order. Like the Android app, the NSE selects these from existing unread state rather than its own record: unread messages newer than their conversation's last_notified_date (Notification.sq), rechecked with the receiving eligibility rules. Only an attempt that processed every event through its target, without deferral or app handoff, presents them; a later complete attempt presents what a deferral held back. The request keeps its own alert, or shows the newest unnotified message when it has none, and returns at most ten others for the host to post. The NSE then advances each candidate conversation's date, so interruption before the host posts can lose those alerts but a later attempt cannot repeat them. Messages older than six hours are marked without an alert.

While it runs, the app maintains the same date as the Android app does: it marks each conversation notified through the newest message it presented, and marks the open conversation even when it shows no alert. Otherwise the NSE alerts unread messages the app received; the age bound limits those, such as after an upgrade. The NSE registers alerted messages and returns delivered identifiers whose message is now deleted or read, for the host to remove; updating delivered alerts remains optional. The host owns the single system completion callback.

Incoming calls

The NSE collects committed calling signals through the target, evaluates one AVS event-only batch after sync and returns its callbacks for the host to report to CallKit. Committed signals are retained in the account's call signal journal for replay through the full app engine, which owns ringing and termination. Besides storing committed signals, the NSE writes only a once-per-signal missed-call claim. AVS exposes no message or session identity, so identical callback references cannot distinguish separate real calls. As with ordinary alerts, interruption after processing can lose presentation; exactly-once reporting is not claimed.

Consequences

The NSE receives a bounded, known set of event families; the NSE README lists them with the dependencies that keep the rest pending. Destructive MLS replay cannot be enabled by discarding its required effects.

Known gaps follow from the decisions above:

  • The backend sends no push for events it delivers over the open socket. A frame the socket delivered but had not stored when it closed, such as one that arrived while pages were fetched, is not alerted; the next sync's paging stores it. A REST sweep after closing would close the gap; it is left out to match Android until the gap shows up in practice.
  • An external commit can succeed remotely before local failure. Only wrong-epoch recovery has a durable intent; other establishment commits need the returned events or welcome for replay, and legacy teammate creation needs an idempotent backend operation or authenticated reconciliation.
  • The NSE uses the server configuration, including the API version, of the app's last preparation. A version the app negotiates later reaches it when the app's sync is next live.
  • CoreCrypto reports malformed MLS input and provider/store failures as the same Other error, so the NSE cannot turn either into a failed-message row.
  • Saving a CRL does not replace earlier in-memory sources or clear native certificate-status caches. An already-used environment can keep a VALID status; a fresh environment reads the saved revocation.
  • Simulator tests do not cover APNs delivery, the real extension lifecycle, shared protected Keychain access or signed-device budgets.

12. Sending From Apple Extensions

Date: 2026-10-13

Status

Accepted. Amended 2026-10-25: uploads have a budget and give the account up when the app needs it.

Context

The iOS share extension must send a message to a conversation the user picks; later, notification actions may send replies, read receipts and delivery receipts. Sending requires a live account: Proteus needs current recipient clients and sessions, and MLS needs the current epoch, otherwise the backend rejects the message (Proteus 412, MLS 409 stale message).

ADR 11 gives extensions an exclusive per-account lease and a slim runtime without CoreLogic, and the NSE can catch up to live over the WebSocket (bounded incremental sync). Message sending, however, lives in :logic (MessageSenderImpl and its envelope, session, MLS, failure and legal-hold collaborators), coupled to :logic repositories and SyncManager.

An extension cannot launch its containing app, has no reliable background time after it is dismissed, and runs under tight memory limits. The backend sends no push for events it delivers over an open legacy WebSocket.

Decision

Extensions send through a slim sender in :domain:messaging:sending, below :logic, composed from the same implementation the app uses. :logic keeps its public use cases and adapts its repositories to the sender's narrow ports; extensions adapt the slim runtime's DAOs and APIs. There is one sending implementation, and no CoreLogic in extensions.

While an extension sends, it keeps the account live: it holds the lease, keeps the WebSocket open, and ingests and processes incoming events, so MLS commits and client changes arrive during the send. Because the backend sends no push for events delivered over that socket, the extension owns their presentation: processed messages owe alerts (ADR 11), and the extension posts them before it releases the account. Before the hold ends it closes and joins the socket.

As the app's sender waits for live, an extension sends only once the socket is live. Pages are fetched only after the socket opens, so no event falls between a page and the socket. When the socket does not reach live within its budget, the extension does not send and stores no message. Like the Android app's one-shot sync, nothing is paged after the socket closes: a frame it delivered but had not stored got no push and is not alerted until the next sync stores it (ADR 11).

The sender never weakens a consent or verification rule to finish in an extension. When sending would need app-only work (a legal-hold change that requires consent, an unacknowledged verification change, an MLS group the client must join or reset, pending proposals that need a commit, a stale epoch, a slow sync), the extension keeps the message as failed and asks the user to open Wire.

The extension processes stored events before it sends. The sender recovers from a stale epoch inside its crypto transaction, and processing events there would deadlock, so the extension declines that recovery instead of waiting for live.

The first share extension sends text, including links as text, to one conversation chosen from the app's conversation list components.

Files follow in the same extension. Uploads can outlive a dismissed extension, so the share sheet stays open while they run: the extension encrypts and uploads every file before it holds the account live, reports progress, and stops when the user cancels. Uploading needs the account's tokens, so the extension holds the account meanwhile, but not its socket, and the live hold's budget starts after the uploads. Holding the account keeps it from the app and the NSE, so uploads have a budget of their own, enough at about 1 Mbit/s and at most five minutes, and they stop when the app needs the account: the app posts a Darwin notification when it enters the foreground or a call arrives, and the extension stops when its host leaves the foreground, because iOS then suspends it with the account held. The extension then releases the account and hands the share to the app (ADR 13), which uploads the files again. The same applies once the hold's socket is live, while it catches up; the connection itself is not interrupted. Once the extension stored a message, the send finishes within its budget: stopping it then could send the message twice. Nothing is stored until every upload succeeded; each file then becomes an uploaded asset message, so a message that fails to send is retried in the app without uploading again. The extension follows the app's file rules: file sharing, allowed types and size limits. Cells files use the app's Cells transport as unpublished drafts. One multipart message is stored with its caption before publication. Before storage, cancellation or rejection cancels those draft versions. After storage, the message owns publication and retry; a failed publish keeps the message retryable. Publication checks server state so retrying a sent-but-unacknowledged publish does not publish the same version again. This path takes explicit files and never reads or consumes the conversation's composer draft.

Implementation

The app's sending was extracted step by step, keeping its behaviour unchanged and its tests covering the moved code through the app's adapters: Proteus envelope creation (MessageEnvelopeCreator), session establishment (SessionEstablisher), MLS message creation (MLSMessageCreator), send-failure handling (MessageSendFailureHandler) and the sending orchestration (MessageSenderImpl), each with narrow ports. Extension adapters over the slim runtime provide a bounded hold that keeps the socket open, processes incoming events and posts unnotified message alerts through the host. The share extension sends text and files.

Consequences

The app and extensions share one sending path, so changes to it affect both. Notification actions and receipts can reuse the slim sender later. The hold still needs validation on signed devices (memory, time and push suppression while the socket is open).

13. Sharing While the App Owns the Account

Date: 2026-10-17

Status

Accepted

Context

ADR 11 lets one process at a time own an account: the app while it is in the foreground or in a call, otherwise the NSE or the share extension, briefly. ADR 12 lets the share extension send by owning the account and keeping it live.

When the app owns the account, the share extension cannot. This is a normal situation: the user shares from inside Wire to Wire, shares from another app during a Wire call, or uses iPad split view with Wire in the foreground. Today the sheet says "Wire is busy" before the user can pick a conversation, because listing conversations also opens the account.

The extension must not act as a second owner. It may not write the account database, use CoreCrypto, refresh tokens, upload, or open a WebSocket while the app owns the account. A pending message written behind the app would not be sent either: on iOS, Kalium does not schedule pending messages, and the app's queries see another process's writes only when it takes the account back.

iOS does not let an app hide its own share extension from its share sheet: excludedActivityTypes applies only to system activities.

Decision

When the app owns the account, the share extension hands the share to the app. The app sends it with its own use cases, as if the user had sent it from the conversation.

The outbox. Each account has an outbox in the App Group, outside the account directory and named with ADR 11's account hash. An entry is a directory with a request and the extension's own copies of the files. The request names the conversation, the text, each file's name, type and media size, and one message ID per item. The extension writes the entry under staging/ and publishes it by renaming it into ready/. A rename within one file system is atomic, so the app never sees a partial entry. The extension then posts a Darwin notification. Entries are protected until first unlock, because the app may import during a call while the device is locked, and are excluded from backups.

Claiming. The app claims an entry by renaming it from ready/ to claimed/, which also tells the extension that the app has it. The app claims only while it owns the account, whether or not it is live yet: when notified, and when it takes an account. Within the app, one importer at a time serves an account. It claims on every notification, even while an earlier import waits for the account to catch up, and imports one share at a time.

Importing. The app imports files first, then the text, as ADR 12 sends them. Each item keeps its message ID from the request, and the app skips an item whose message already exists. A crash or a background handoff during an import therefore stores nothing twice: the next import finishes the entry. Catching up can change the rules, so the app first waits until the account caught up, then checks the conversation with the extension's own rules (ADR 12's share blocks and file rules). If a rule now refuses it, the app stores nothing. The import stores each message itself; only sending goes on without it, so an import cancelled before a message is stored stores and sends nothing. Stored messages belong to the app: they upload, send, fail and retry in the conversation like messages from the composer. The app then writes a result for the extension (stored, or the reason it refused) and removes the entry.

The picker. The extension cannot list conversations while the app owns the account, and it must not open the app's database. It asks the app with a Darwin notification. For each account it owns, the app writes a snapshot: the account name, its file rules, and the conversations the extension may share to, built by the same code as the extension's own list. The extension deletes any old snapshot before it asks, and deletes each snapshot once read. A snapshot holds conversation names, so it is written only on request, protected while the device is locked, and excluded from backups. The app answers these requests on their own, so a share being imported never keeps a sheet waiting for its list.

The sheet. When the app does not answer, the account is busy for another reason, usually the NSE. Then the extension retries opening the account itself until its budget ends. If the account becomes busy between listing and sending, the extension retries sending for a moment, then hands the share off. It also hands the share off when the app wants the account while the extension uploads, or when uploading takes too long (ADR 12). The sheet waits a few seconds for the app to claim the share, and a few more for its result. If the app writes a refusal in time, the sheet shows the reason. Otherwise the sheet says Wire is sending the share, or, if the app has not claimed it, that Wire will send it when Wire is opened. It also marks the entry as unwatched: from then on, the app tells the user with a local notification if it refuses the share. The app writes its result before it looks for the mark, and the sheet marks before it looks one last time for the result, so at least one of them sees the result; only one takes it. The sheet offers no cancel while it waits.

Not a second lock. A lock that only the app holds would tell the extension that the app owns the account. It would not tell it that the app will take the share, and checking two locks one after the other races with the app releasing them. The app's claim answers both.

Implementation

Kalium's share module holds the outbox (entries, claims, results and snapshots), which the extension and the app both use, and the app's importer and snapshot writer, which take the app's sending as ports. The app runs them with serveShareExtension, which stores each item through the composer's text and asset use cases under the item's message ID. The iOS app posts and observes the Darwin notifications and runs the importer in each account's foreground and call work; the share extension shows the snapshot picker, hands the share to the app, waits for its answer and falls back as described above.

Consequences

Only the app imports. A share left in the outbox waits for the app to own the account live: the share extension and the NSE do not send other shares, which keeps their memory and time budgets unchanged. Entries and snapshots add plaintext copies in the App Group, protected like the account's own files and removed once imported or read. A share that a rule refuses at import was shown as allowed in the picker; the sheet reports it if the app's answer arrives in time, and the app reports it otherwise.

14. Keyset pagination and a smaller message list model

Date: 2026-09-05

Status

Accepted

Context

The existing message timeline uses numeric offsets over MessageDetailsView and returns full Message.Standalone objects. MessageExtensionsImpl.getPagingSource uses AsyncQueryPagingSource. Each load counts messages and selects from MessageDetailsView with ORDER BY date DESC LIMIT ... OFFSET .... The view includes full sender details, asset credentials, unknown and failed-decryption payloads, quotes, and collection aggregates. Multipart attachments then require one additional query per multipart message.

The Android app requests 20 messages per page, 60 initially, and prefetches at a distance of 30. Its mappers use only part of each full message, but also need enough information for every supported bubble, system event, status, quote, reaction, and attachment. Some system events and delivery failures cause additional user queries during per-message mapping. Android also uses placeholders and numeric positions for unread and searched-message navigation.

These are code findings, not measured performance results.

Decision

Add a separate API that uses keyset pagination over message keys and a dedicated MessageListItem model containing the data needed to render the timeline. The new path avoids deep-offset traversal and unused message details. See the API guide for session lifetime, anchors, and adoption.

  • Add a separately named keyset entry point. Preserve the existing API's signatures, full-message return types, offset semantics, and behavior. Consumers opt into the new API explicitly.
  • Order each conversation by creation_date DESC, id DESC. Use the exact stored timestamp and SQLite's ID ordering in the cursor. The ID resolves timestamp ties deterministically.
  • Select a bounded page of keys using an indexed cursor predicate, then load list data only for those keys. Return at most the requested number of items, with one extra key used to detect another page. Batch attachments and related users for the selected page.
  • Introduce a typed MessageListItem contract. Keep rendering data and references for actions. Resolve full details through existing message and asset APIs when an action needs them. Do not construct full messages and discard fields afterward.
  • Provide latest, older, newer, and around-message loading. Consumers adopting the new API replace timeline positions and placeholders with message anchors. Keep unread counts for badges, outside the new paging path.
  • Keep the query and list model in shared code. Expose concrete page and result types usable by Swift, with an Android Paging adapter for the current app. Compose and SwiftUI retain their native UI behavior. This work does not introduce a module or a general pagination framework.
  • Treat pages as part of an invalidatable database generation. Refresh around the visible message when relevant data changes, including changes to the stored timestamp. A cursor does not freeze a message's position across writes.

The active path is chronological. PendingMessagesPagingSource and pending-specific SQL exist, but getPagerForConversation does not use them. The keyset API preserves the active ordering. A future pending-first mode needs a separate ordering contract.

Rollout gates

Client adoption waits for these gates. Release gates are semantic parity, no offsets or per-load total count in the new timeline path, indexed cursor traversal, page-scoped child queries, and lower measured data/allocation cost on the mixed-content fixtures. Deep-page timing must not grow with the number of earlier pages; investigate filter selectivity and query plans if it does. First-load and write regressions need an explicit explanation before adoption. Set device-specific latency and memory thresholds from a recorded baseline rather than a predicted percentage improvement.

The message timeline benchmarks cover offset/full entities, keyset page assembly, public list mapping, and around-message loads on the JVM and Android. An isolated keyset/full-view control still needs coverage. Device measurements also need the iOS production driver, cold/warm runs, p50/p95 query and end-to-end mapping time, SQL statement count, mapped object count, allocated bytes, retained heap, index size, write cost, and frame timing while scrolling and applying incoming messages.

Consequences

Adding only a cursor to MessageDetailsView would reduce offset work while retaining unnecessary data and per-message attachment queries. A smaller projection with offsets would retain deep-page traversal. The keyset API addresses both costs.

A permanently materialized timeline table would require extra writes and synchronization when users, receipts, reactions, quotes, or attachments change. Start with indexed reads from existing tables and page-scoped batch queries. Consider persisted summaries only if measurements show a remaining problem that needs them.

Keyset pages do not provide arbitrary global row numbers. Android must migrate navigation, scroll-to-latest, and refresh anchoring together with paging. Equal-timestamp messages gain a defined order that the existing query does not specify. Relevant writes can still move messages; generation invalidation and anchor restoration handle that case.

Support and test both pagination APIs during the initial adoption period. Target deprecation of the offset timeline API a few months after the new API ships, with the exact release recorded in the migration tracking issue. Deprecation keeps the old API available and working; it does not remove it. Decide removal separately after consumers migrate and the supported compatibility window closes, following ADR 9. Do not add an offset-to-cursor adapter that scans history or silently route existing callers through the new contract. Full-message detail APIs remain supported. Search-result lists, media galleries, exports, and remote history cursors remain separate work.

SQLite documents the deep-offset cost and indexed tuple alternative in its scrolling-window example. Android Paging supports custom keys and refresh anchoring through PagingSource.

15. Keyset-Paginated Conversation List

Date: 2026-09-04

Status

Accepted

Context

The existing conversation-list flow is a public compatibility surface built on a wide joined view, a count query, and offset pagination. Deep loads still scan or sort preceding rows, unread aggregation is not restricted to the requested page, and the returned conversation details contain substantially more data than the list renders.

Decision

Add a separate common Kalium conversation-list flow and leave the legacy flow, models, configuration, and behavior unchanged. The new public API will return a dedicated sealed ConversationListItem hierarchy for the four currently paginated sources: all conversations, groups, channels, and one-to-one conversations; archive mode and contains-search remain supported, while favorites and custom folders remain on their existing flows.

The conversation list guide describes the public contract.

Order eligible conversations only by stored Conversation.last_modified_date descending and qualified conversation ID ascending. Do not order by name, unread state, pending-connection update time, or joinable calls, and do not change the existing writers of last_modified_date. A public ConversationListPosition will contain the old activity time and conversation-ID tie-breaker so the app can restore the same approximate ordering neighborhood without following a conversation that later moved.

Use a cursor-keyed Paging source with no count, placeholders, or arbitrary jumps. Normal loading appends older items; prepend and centered refresh are also supported so an invalidated or restored deep viewport can reach both newer and older items. Android will continue to own pixel-offset and query-identity persistence.

For each load direction, use an AS MATERIALIZED CTE to select the eligible page keys and apply the keyset boundary, ordering, and limit there. Hydrate only those keys with the narrow row projection, saved-draft or last-message preview, and five page-local unread counters. This requires SQLite 3.35 or newer; bundling a modern SQLite binary for the unencrypted Android path and supporting older SQLite runtimes are outside this decision.

Reuse the existing conversation-list membership, order, last-message, and unread invalidation keys, together with the MessageDraft table notification key. Draft writes invalidate loaded pages so previews update without rebuilding the pager on return. Folder changes and call changes do not invalidate the new Paging source. Joinable-call state remains an Android-observed overlay rather than part of the Kalium page or its ordering.

Add a global composite recency index and benchmark a second type-prefixed recency index. Ship the type-prefixed index only if it materially improves sparse filtered sources without increasing end-to-end message-write median or p95 latency by more than 10 percent.

Considered Options

  • Reusing the legacy view with a cursor was rejected because it would retain most of the projection, join, grouping, and first-page costs.
  • A separate visible-row unread stream was deferred because indexed page-local counters retain one self-contained list flow; it can be reconsidered if invalidation churn is still material.
  • A timestamp-only restoration position was rejected because conversations can share a millisecond timestamp; the conversation ID is retained only as the old position's deterministic tie-breaker.
  • A new activity-time invalidation key was rejected because the existing order key is correct and substantially simpler, despite occasional unnecessary invalidation from pending-connection timestamp changes.
  • Full-text search was deferred; contains-search keeps its current semantics and is benchmarked separately because keyset pagination cannot make a leading-wildcard search bounded.

Consequences

  • Pending connections no longer receive special recency from Connection.last_update_date; they use the stored conversation activity time like every other list item.
  • Process-death restoration requires the app to save the list position and pixel offset. The internal page cursor remains composite and deterministic.
  • The new API and models are additive public ABI. Legacy consumers continue to use the unchanged offset flow.
  • Non-search loads must avoid count queries, global unread scans, and temporary ordering B-trees. Correctness tests must cover equal timestamps, membership parity, append, prepend, centered refresh, invalidation, and missing or duplicated rows.
  • The accepted performance gates compare the new and legacy flows on representative Android SQLCipher data: first-page median at most 50 percent of legacy, middle/deep median at most 1.25 times the new first-page median, and deep-page p95 at most 1.5 times the new first-page p95.

16. Native End-to-End Encrypted Polls

Date: 2026-10-06

Status

Accepted

Context

Polls are currently possible only through services or bots that send composite messages with buttons. Composite messages store a text body and a list of buttons. ButtonAction tells the composite author which button a user picked, and ButtonActionConfirmation from the author marks the self user's choice as selected. The bot owns the result. Clients store only their own selection, so they cannot show counts, percentages, or who voted for what.

Bot polls must keep working for existing service integrations.

Decision

Add native poll content to Kalium as a separate message type. Do not extend composite messages. Bot polls keep COMPOSITE, ButtonContent, ButtonAction, and ButtonActionConfirmation unchanged.

Protocol

Add two GenericMessage oneof entries:

  • Poll poll = 31, a regular timeline message with the question, ordered options (stable id and text), allow_multiple_answers, and the standard expects_read_confirmation and legal_hold_status flags. The message id is the poll id.
  • PollVote pollVote = 32, a signaling message with the poll message id and the sender's complete current selection. An empty selection retracts the vote.

Both are sent through the normal end-to-end encrypted pipeline. No bot or server takes part in voting or tallying.

Votes are never anonymous. Every client receives the sender of each encrypted vote and needs it to keep one current vote per user. A setting that only hides names in the UI would suggest an anonymity guarantee that the protocol does not give, so there is no such setting.

A Poll is sent with unknownStrategy = DISCARD_AND_WARN. Clients without poll support show an unsupported-message notice instead of silently dropping it. PollVote keeps the default IGNORE. The ephemeral message format cannot carry polls, so polls are never self-deleting. Clients should not offer poll creation while self-deleting messages are active.

Storage and rules

  • PollContent stores the question, allow_multiple_answers, and the options as JSON encoded in Kotlin. Options never change after creation. Kotlin encoding means option text is never escaped by SQL string concatenation.
  • PollVoteContent stores the latest vote of each user, keyed by conversation, poll, and voter. A vote replaces the stored vote only if it is not older, so out-of-order delivery cannot bring back an old selection. A retraction is stored as an empty selection for the same reason.
  • A vote is valid when the poll exists locally, every option id belongs to the poll, and a single-answer poll has at most one option. Repeated ids are removed. PollMessageDAO.recordVote checks this in the same transaction that stores the vote. Invalid votes and votes for unknown polls are dropped and never buffered, so a client's result only depends on the history it has.
  • Received polls drop options with a repeated id.
  • Deleting a poll for everyone removes its poll content and votes.
  • POLL counts as a last-message type. Conversation lists, previews, and notifications show the poll question.
  • The legacy database backup exports and imports both poll tables.
  • The message timeline loads poll content, votes, and voters with each page as MessageListContent.Poll, and a new vote invalidates the open timeline.
  • The notification service extension stores received polls and votes with the same validation as the app, so polls and votes that arrive in the background are not lost.

Sending

SendPollMessageUseCase validates 2 to 10 distinct, non-blank options and a non-blank question. It persists the poll and sends it like a location message, including the pending-message retry.

SendPollVoteUseCase stores the self user's vote before sending it, so results update at once. If sending fails, it restores the previous vote, unless a newer vote replaced it in the meantime, and returns a failure so the UI can tell the user.

Replies

A text message can quote a poll. A quote carries the SHA-256 of the quoted message, so a reader can check that the person replying saw the same content. Proteus encrypts a copy for each recipient, so a sender could show recipients different options under one poll id. The poll hash therefore covers everything that is fixed at creation, in this order:

  • the bytes FE FF;
  • the question;
  • the number of options, as a 4-byte big-endian integer;
  • each option's id and text, in the poll's order;
  • allow_multiple_answers, as one byte, 1 or 0;
  • the poll's time in seconds, as an 8-byte big-endian integer, as for text.

Each string is UTF-16BE, preceded by its length in bytes as a 4-byte big-endian integer, so two different polls never encode to the same bytes. Votes are not part of the hash. QuotedMessageDetails.Poll gives the UI the question. Clients without poll support cannot verify the quote and show it as unavailable, like a quote of any message they cannot read.

Consequences

Poll creation and voting stay end-to-end encrypted and work without bots. Every client derives the same result from the votes it has received. A client that missed the original poll ignores later votes for it.

Older clients show an unsupported-message notice for native polls. Bot polls behave as before.

The UI decides how to present the results. It must not suggest that votes are anonymous.

Tracked asset message sends

An application may need to finish an asset message send after its conversation screen closes and keep its account available until sending or cancellation has finished. Kalium exposes a tracked upload handle with an identity for each attempt, the persisted message identity, terminal completion and cancellation for that attempt. Completion follows both the asset upload and the final message send, including removal of the message-visibility monitor and cancellation cleanup; the existing scheduler remains available for callers that only need confirmation of local persistence.

A caller cancelled before receiving a handle waits for any started attempt to stop; cancellation marks the persisted message as failed so it remains retryable. Retries recheck the current file-sharing policy, retain the persisted message identity and reuse its remote asset when uploading already succeeded. Tracked attempts disable automatic pending-message resend so an application retry has one owner; the existing scheduler retains its configured resend behavior. Progress reports actual lifecycle phases because the current upload repository exposes no transferred-byte count.

Persisted-message retries can use RetryFailedMessageAndAwaitUseCase.invoke to keep upload and final send in the caller's structured lifetime. Cancellation leaves unfinished messages retryable; an already successful send is not rewritten as failed.

Cells uses its existing coordinator with a cancellation barrier that joins the selected worker and transfer cleanup. Attachment drafts opt into a session-owned registry: accepted operations wait for an application owner to claim them before preparation starts. Cancellation captures a particular transfer attempt, so delayed cleanup cannot cancel a later retry of the same node. Terminal outcomes are explicit; disappearance from the active upload map is not evidence of success. Android callers that do not opt in retain the existing scheduling path.