Got a BREAK?¶
A decision path for a reported break: fix it, accept it, rename it, or bump the major
version. It is for the developer whose PR just failed. A BREAK means a reader throws in the
stated direction: backward (new code reads old data) or
forward (old code reads new data). Read the finding first:
it names the type, the rule, the direction and a fix: line.
flowchart TD
A[BREAK on your PR] --> B{Was the change intended?}
B -- No --> F[Fix the model]
B -- Yes --> C{Is it a type rename?}
C -- Yes --> R[Declare a rename]
C -- No --> D{Does any reader or stored data still depend on the old shape?}
D -- Yes --> M[Bump the major version, then accept the break]
D -- No --> X[Accept the break]
-
The change was an accident. Change the model, not the gate.
-
Deliberate, and nothing still depends on the old shape.
-
A type's serial name moved. Declare it, and it stops reading as remove plus add. A renamed property is still accepted by hand.
-
Old data or old readers can't keep working.
Fix it¶
Most breaks are accidents, and the report's fix: line says what to do. The common fixes,
grouped by what changed. Every rule, in both directions, is in the matrix.
| What changed | Usual fix | Rules |
|---|---|---|
| Fields | ||
| A required field was added | Give it a default, or make it nullable with explicitNulls = false. Readers should set ignoreUnknownKeys (forward). In CBOR only a default works |
PROPERTY_ADDED |
| A field was removed | Keep it (deprecate it in code) until no reader or stored data uses it | PROPERTY_REMOVED |
| A field became required | Keep a default so older payloads still decode | PROPERTY_OPTIONALITY |
| A field became optional | Annotate it @EncodeDefault(ALWAYS), set encodeDefaults = true on the writer, or keep it required |
PROPERTY_OPTIONALITY |
| A field's nullability changed | Keep it nullable, or introduce a new field | PROPERTY_NULLABILITY |
| A field's type changed | Introduce a new field instead of changing the type | PROPERTY_TYPE_CHANGED |
A @JsonNames alias was dropped |
Keep the alias while any producer may still send that key | PROPERTY_JSON_NAMES |
| Enums | ||
| A value was added | JSON: enable coerceInputValues and give the reading field a default. CBOR never coerces: ship the reader first, or bump major |
ENUM_VALUE_ADDED |
| A value was removed | Keep the value (deprecated) until no persisted or old data uses it | ENUM_VALUE_REMOVED |
| Polymorphism | ||
| A subtype was added | Register a default deserializer on old readers, or bump major | SUBTYPE_ADDED |
| A subtype was removed | Keep it until no old or persisted data uses it | SUBTYPE_REMOVED |
| The discriminator key or value changed | Don't change it. It breaks all polymorphic decoding | DISCRIMINATOR_CHANGED, DISCRIMINATOR_VALUE_CHANGED |
| A property collides with the discriminator | Rename the property, or set a different @JsonClassDiscriminator |
DISCRIMINATOR_COLLISION |
| Types and config | ||
| A whole type was removed | Keep it while data or peers still use it | CONTRACT_REMOVED |
A Json { } or Cbor { } setting changed |
Revert it, or accept the change if every reader moves together | CONFIG_NAMING_STRATEGY, CONFIG_DISCRIMINATOR, CONFIG_READER_STRICTNESS, CONFIG_ENCODE_DEFAULTS, CONFIG_EXPLICIT_NULLS, CONFIG_COERCE_INPUT, CONFIG_ARRAY_POLYMORPHISM, CONFIG_STRUCTURED_MAP_KEYS, CONFIG_SPECIAL_FLOATS, CONFIG_FORMAT, CONFIG_CHANGED, CONFIG_CBOR_BYTE_STRING, CONFIG_CBOR_DEFINITE_LENGTH, CONFIG_CBOR_TAGS (see the config and CBOR-only groups) |
| CBOR | ||
A @CborLabel changed |
Keep it stable once labels are preferred, or bump major | PROPERTY_CBOR_LABEL |
@ByteString was added or removed |
Keep the ByteArray encoding stable; introduce a new field instead |
PROPERTY_BYTE_STRING |
@KeyTags, @ValueTags or @ObjectTags changed |
Keep them stable: a reader that verifies tags rejects any other tags. Or bump major | CBOR_TAGS_CHANGED |
A @CborArray layout changed |
Only append optional elements, and only when old readers never see new data; otherwise bump major | CBOR_ARRAY_LAYOUT |
A @CborArray element is omitted at its default |
Annotate it @EncodeDefault(ALWAYS), set encodeDefaults = true, or move it to the end |
CBOR_ARRAY_OMITTED_DEFAULT |
For example, adding a field with a default keeps old payloads decodable:
@Serializable
data class Order(
val id: String,
+ val currency: String = "USD",
)
After the fix
The finding disappears from the next run. Nothing was accepted, so the gate is as strict as before.
A COVERAGE_GAP is not a break to fix in the model. See Limitations.
Accept it¶
If the break is deliberate, for example every consumer has stopped using a field, add the
finding to acceptedBreaks. It stops failing the build but still appears in the report, so
the break stays visible. Copy the type, rule and direction from the finding.
serialkompat {
acceptedBreaks.set(listOf(
"com.example.wire.Payment PROPERTY_REMOVED BACKWARD",
))
}
Sanctioning a deliberate break¶
The format is "<serialName> <RULE> [DIRECTION]":
- the type's serial name,
- the exact rule ID (see Rules),
- an optional direction. Omit it to accept the finding in every direction you check, or give
BACKWARDorFORWARDto accept it in only one.
An entry only covers findings with that serial name and rule. Every other finding on the
type, or under that rule elsewhere, still fails. A sealed or polymorphic subtype is reported
as Base/sub. Write Base/sub to accept the break under that base only, or the bare sub
to accept it under every base.
These are accepted breaks in these docs. The console report calls them "acknowledged" and keeps them in a separate count, so an accepted break stays visible.
Don't accept to silence an unintended break
An accepted break is a promise that nobody still depends on the old shape. If you can't say who stopped using it, fix it or bump the major version instead. Remove the entry once the break has shipped, so the same rule isn't silently accepted again next time.
Declare a rename¶
A type rename reads as a removal plus an addition unless you declare it. A property rename can't be declared: serialkompat doesn't pair a removed property with an added one.
-
A type's serial name changed: declare it in
renames. serialkompat then compares the two types' contents instead of reportingCONTRACT_REMOVED.build.gradle.ktsserialkompat { renames.put("com.example.wire.LegacyOrder", "com.example.wire.OrderEvent") } -
A property was renamed (JSON):
renamesis for types only. Keep the old name readable with@JsonNames, so new code still decodes old payloads.import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonNames @OptIn(ExperimentalSerializationApi::class) @Serializable data class Order( @JsonNames("note") val memo: String? = null, )
The gate still reports the old name as PROPERTY_REMOVED and the new one as
PROPERTY_ADDED, because it doesn't pair them. Once you've confirmed the alias covers
your data, accept whichever of those the report fails, for example
"com.example.wire.Order PROPERTY_REMOVED BACKWARD".
CBOR has no such bridge: @JsonNames is ignored, and a renamed field is a remove plus an
add. See CBOR.
Bump major¶
If old data or old readers really can't keep working, ship the break as a new major version.
Accept the break (above) in the same PR, and note it in your changelog. Once that PR is
merged into baselineRef, later changes are compared against the new shape. If you keep
history, consider whether the horizon should now start at the new major
(sinceVersion).