Skip to content

Bug Report: Room @Embedded Silently Drops All Columns for @Serializable Types from External Compiled Modules (KSP2 + Kotlin 2.2.0) #2823

@suhaibkazi

Description

@suhaibkazi

Summary

When using Room 2.7.2 + KSP 2.2.0-2.0.2 + Kotlin 2.2.0, Room's @Embedded annotation silently generates zero SQL columns for any @Serializable (kotlinx-serialization) data class that is defined in an external compiled module (AAR/JAR dependency). No error is emitted. No KSP warning is produced. The database schema is generated as if the @Embedded field does not exist. This is a silent data-loss regression — it produces a narrower schema than expected, which causes runtime crashes on migration from any previously-working schema version.


Environment

Component Version
Kotlin 2.2.0
KSP 2.2.0-2.0.2
Room 2.7.2
kotlinx-serialization plugin 2.2.0
kotlinx-serialization-json 1.9.0
AGP 8.11.1

Minimal Reproduction

External library module (compiled to AAR, Kotlin 2.2.0):

// In :my-library module
@Serializable
data class Address(
    val street: String = "",
    val city: String = "",
    val country: String = "",
    val postCode: String? = null,
)

Consumer app module (Room entity, Kotlin 2.2.0 + KSP 2.2.0-2.0.2):

@Entity(tableName = "persons")
data class PersonEntity(
    @PrimaryKey(autoGenerate = true)
    val id: Int = 0,

    val name: String = "",

    @Embedded(prefix = "address_")
    val address: Address? = null,  // ← all 4 columns silently missing
)

Expected generated schema:

CREATE TABLE persons (
    id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
    name TEXT NOT NULL,
    address_street TEXT,
    address_city TEXT,
    address_country TEXT,
    address_postCode TEXT
)

Actual generated schema — all @Embedded columns are missing, zero KSP warnings:

CREATE TABLE persons (
    id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
    name TEXT NOT NULL
)

What Does NOT Reproduce (Critical Contrast)

If Address is defined in the same app module (not an external dependency), @Embedded works correctly — even when Address is still annotated with @Serializable:

// Defined in the same :app module → @Embedded works fine
@Serializable
data class Address(
    val street: String = "",
    val city: String = "",
    val country: String = "",
    val postCode: String? = null,
)

This confirms the bug is not about @Serializable per se — it is strictly about @Serializable types consumed from external compiled modules.


Root Cause (Proven by Bytecode Analysis)

The failure is a three-component interaction between the kotlinx-serialization K2 plugin, KSP2's metadata reader, and Room's field filter.

Step 1 — kotlinx-serialization K2 plugin writes empty JVM field signatures

The kotlin.Metadata annotation (@Metadata) on a compiled Kotlin class includes a JvmPropertySignature protobuf extension for each property. Under K1, this was populated with the actual JVM field name and descriptor, e.g.:

JvmPropertySignature {
    field { name: "street", desc: "Ljava/lang/String;" }
    getter { name: "getStreet", desc: "()Ljava/lang/String;" }
}

Under the K2 serialization compiler plugin (Kotlin 2.2.0), the field entry is written as a zero-length message — present in the proto, but with no name or desc fields set (0x0a 0x00 in the encoded bytes). The getter signature remains correctly populated. The JVM backing fields themselves are present in the bytecode (private final String street; etc.) — only the metadata description of the field is empty.

This was verified directly by extracting and decoding the kotlin.Metadata d1 protobuf from the compiled class. All 4 properties of a @Serializable data class compiled from an external module had empty field signatures. Zero had a properly populated field signature.

Step 2 — KSP2 reads empty field signature as hasBackingField = false

Room's KSP processor calls KSPropertyDeclaration.hasBackingField to determine whether a property represents a real database column. In KSP2 (K2 mode), hasBackingField is resolved by reading the JVM field signature from the deserialized kotlin.Metadata. Since Step 1 left the field signature empty/unpopulated, KSP2 reports hasBackingField = false for every property of the external @Serializable class.

When KSP2 processes a source-module class (same compilation unit), it reads from the live FIR AST rather than deserializing metadata, so the empty field signature issue never surfaces — this is why the same @Serializable type works when defined in the app module.

Step 3 — Room silently drops all fields

Room's KspTypeElement.getDeclaredFields() is implemented as:

declaration.getDeclaredProperties()
    .filter { it.hasBackingField }  // ← filters out ALL properties
    .map { KspFieldElement.create(env, it, this) }

With hasBackingField = false for every property, the filter returns an empty list. Room's @Embedded processor receives zero fields for the type, generates zero columns, and emits no warning or error.

The failure chain

kotlinx-serialization K2 plugin
  └── compiles @Serializable data class into AAR
      └── writes JvmPropertySignature.field = empty message (0x0a 0x00)
            ↓ KSP2 deserializes kotlin.Metadata from external AAR
            └── KSPropertyDeclaration.hasBackingField = false (reads empty field sig)
                  ↓ Room KspTypeElement.getDeclaredFields()
                  └── .filter { it.hasBackingField } → []
                        ↓ Room @Embedded processor
                        └── Zero columns generated — silently

Regression Point

This worked correctly with Kotlin < 2.0 / KSP1. The K1 KSP implementation (DescriptorResolver) derived hasBackingField from the Kotlin compiler descriptor's backingField property directly, bypassing the JVM field signature in the metadata entirely. KSP2 switched to reading from the serialized JVM metadata extension, which exposed the empty-field-signature written by the K2 serialization plugin.


Severity

High / Data Loss. The failure is completely silent — no build error, no warning from KSP, no Room annotation processing error. The only observable symptom is a narrower-than-expected Room schema, which crashes at runtime with IllegalStateException: Migration didn't properly handle on any device with an existing database. Any project that uses @Embedded with @Serializable types from a library dependency is silently affected after upgrading to Kotlin 2.2.0.


Suggested Fix Owners

  • kotlinx-serialization team (JetBrains): The K2 compiler plugin should write a properly populated FieldSignature (with name and desc) for data class primary constructor properties, consistent with the K1 plugin behavior.
  • KSP team (Google): KSPropertyDeclaration.hasBackingField in K2 mode should not return false when the JVM backing field provably exists in the bytecode but the metadata field signature is empty. A fallback to bytecode inspection or to the K2 FIR property's hasBackingField flag would prevent downstream tools from being broken.
  • Room team (Google/AndroidX): Room's @Embedded processor emitting no diagnostic when a type resolves to zero fields is a usability gap regardless of the above. A warning such as "@Embedded type X resolved to zero fields; check that the type is visible to KSP" would have surfaced this immediately.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions