Compatibility Reference
Reference documentation for Protean's IR compatibility checking system, the rules that classify changes as safe or breaking, the three-tier taxonomy, and the deprecation lifecycle.
For the how-to guide on setting up compatibility checks, pre-commit hooks, and CI integration, see Compatibility Checking.
Breaking change rules
Protean classifies changes to persisted domain elements using these rules:
| Change | Classification |
|---|---|
| Add optional field (or with default) | Safe |
| Add required field without default | Breaking |
| Remove field from any persisted element | Breaking |
| Remove a field deprecated past its removal version | Safe (expected removal) |
Remove a field from an event-sourced aggregate that declares the name reserved |
Safe (reserved) |
Rename a field via renamed_from, same type |
Safe (field_renamed) |
| Rename a field and change its type | Breaking (field_type_changed) |
| Change field type | Breaking |
| Remove an element | Breaking |
| Add a new element | Safe |
| Visibility public to internal | Breaking |
| Visibility internal to public | Safe |
Change __type__ string |
Breaking |
| Event version bump covered by a registered upcaster | Safe (mitigated) |
| Turn event sourcing on or off for an aggregate | Breaking (event_sourcing_changed) |
Move an event-sourced aggregate's stream_category |
Breaking (stream_category_changed) |
| Point an event-sourced aggregate's identity at another field | Breaking (identity_field_changed) |
Drop an @apply handler while its event survives |
Breaking (apply_handler_removed) |
Drop a name from an event-sourced aggregate's reserved |
Breaking (reservation_removed) |
Most of these rules apply to every persisted element: aggregates, entities,
value objects, commands, events, database models, and projections. Six rows are
the exception: the last five, and the reserved field-removal row above them.
They read attributes only an aggregate has, so they are checked on event-sourced
aggregates and nowhere else.
Replay hazards on an event-sourced aggregate
An event-sourced aggregate's authoritative state is a stream of events, rebuilt by reading that stream and applying the events in it. A snapshot may be stored alongside it, holding serialized aggregate state, and that snapshot is a rebuildable cache: Protean discards one that no longer constructs and replays the stream instead. So the checker reports the changes that stop that rebuild, whether or not the aggregate's fields moved.
The stream an aggregate reads is named f"{stream_category}-{identifier}", so
moving the stream category leaves the whole history under the old name, and
pointing the identity at another field asks for a stream keyed by a value no
writer ever used. Either way a load of an existing aggregate finds nothing.
Turning event sourcing on or off changes where state lives, and the old state
cannot be read the new way. Dropping an @apply handler while its event survives leaves
historical events of that type with nothing to apply them, and the rebuild
raises. Dropping a name from reserved takes back the field removal that
declaration earned: replay stops dropping an assignment to the name, so a
retained handler that still writes it raises again.
Some related cases are already covered by the general rules above and are not
reported again here: a change to the identity field's type is a
field_type_changed, and an event removed from the domain outright is an
element_removed on that event. Deleting an event class and its @apply
method together is one act, so it reports once, as the removal.
Evolution-aware classification
The checker understands three evolution mechanisms:
- Reserved field on an event-sourced aggregate: Removing a field from an
event-sourced aggregate is safe only when the aggregate declares the field name
in
reserved. The declaration does the migration: during replay an assignment to a reserved name (from a retained@applyhandler for a retired event) is dropped instead of raising, so the aggregate still rebuilds. Only the aggregate's own field removal is earned this way, and only when the aggregate is event-sourced on both sides. A type change or a newly required field stays breaking (a stored snapshot can survive a type change and skip replay, and replay runs no required-field check), and the replay hazards above still report on their own. Reusing a reserved name for a live field raises at registration. - Deprecation grace: A field marked
deprecatedand removed at or past itsremovalversion is an expected removal (safe), not a breaking one. This applies to every persisted element, including internal and event-sourced events (not only published event contracts). It compares against the release version; theprotean ir diffCLI does not yet supply one, so this downgrade applies when the diff is run with an explicit current version. - Upcaster mitigation: When an event's
__version__bumps and a registered upcaster chain covers the old→new version path, the schema-transformation changes that make up that bump, field removals, type changes, required-field additions, and the__type__version-string change, are downgraded from breaking to safe, each citing the mitigating upcaster. An orthogonal change riding along with the bump (for example a public→internal visibility flip) stays breaking, and a version bump with an upcaster gap (a prior version with no path to the new one) stays breaking.
Three-tier breaking change taxonomy
Protean follows a tiered approach to breaking changes (see ADR-0004 for the full rationale):
Tier 1: Surface breaks
Renamed classes, moved imports, changed signatures.
Mitigation: Introduce the new API alongside the old. The old API emits
DeprecationWarning with a specific removal version and delegates to the
new implementation. Minimum survival: 2 minor versions.
import warnings
def old_method(self):
warnings.warn(
"old_method() is deprecated. Use new_method() instead. "
"Will be removed in v0.17.0.",
DeprecationWarning,
stacklevel=2,
)
return self.new_method()
Tier 2: Behavioral breaks
Same signature, different behavior.
Mitigation: Introduce new behavior behind a configuration flag, defaulting to old behavior. Minimum survival: 3 minor versions.
Transition timeline:
| Version | State |
|---|---|
| v0.N | New behavior is opt-in (flag defaults to old) |
| v0.N+1 | Warning emitted if flag is unset |
| v0.N+2 | Default flips to new behavior |
Tier 3: Structural breaks
Persistence format, event schema, serialization changes.
Mitigation: Version the schema or format explicitly. Document exact migration steps in the release's Upgrade Notes. Provide a migration script or CLI command where feasible.
The IR compatibility checker (protean ir diff) focuses on Tier 3
structural changes.
Deprecation lifecycle
When deprecating a domain element or field:
- Mark as deprecated with a
DeprecationWarningthat includes the removal version (see ADR-0004 for the deprecation pattern). - Keep the deprecated API for at least
min_versions_before_removalminor versions (default: 3, configurable in.protean/config.toml). - Add to
excludein.protean/config.tomlif the element should not trigger breaking change alerts during its deprecation period. - Remove in a later release, once the declared removal version has arrived and has been announced.
The protean ir diff command distinguishes expected removals (deprecated
elements past their removal version) from unexpected removals.
Events additionally accept a superseded_by option naming the replacement (an
Event class or a name string):
@domain.event(
part_of=Order,
deprecated={"since": "0.16", "removal": "0.19"},
superseded_by=OrderPlacedV2,
)
class OrderPlaced(BaseEvent): ...
The successor is surfaced two ways: raising a deprecated event emits a
ProteanDeprecationWarning (once per event type) that names it, and the
DEPRECATED_ELEMENT diagnostic appends superseded by \
.protean/config.toml reference
All settings are optional, sensible defaults apply when the file is absent.
[compatibility]
strictness = "strict" # "strict" | "warn" | "off"
exclude = ["myapp.internal.LegacyEvent"]
[compatibility.deprecation]
min_versions_before_removal = 3
[staleness]
enabled = true
[domains]
identity = "identity.domain"
catalogue = "catalogue.domain"
ordering = "ordering.domain"
[compatibility]
| Key | Type | Default | Description |
|---|---|---|---|
strictness |
string | "strict" |
"strict" exits non-zero on breaking changes. "warn" reports but allows. "off" skips checking entirely. |
exclude |
list of strings | [] |
Fully-qualified names of elements to exclude from compatibility checks. |
[compatibility.deprecation]
| Key | Type | Default | Description |
|---|---|---|---|
min_versions_before_removal |
integer | 3 |
Minimum minor versions a deprecated element must survive before removal. |
[staleness]
| Key | Type | Default | Description |
|---|---|---|---|
enabled |
boolean | true |
Whether the staleness check (protean ir check) is active. Set to false to skip. |
[domains]
Maps logical domain names to their module paths. When present, pre-commit hooks
iterate over all configured domains automatically, no --domain argument
needed. Each domain's IR is stored under .protean/<name>/ir.json.
| Key | Type | Description |
|---|---|---|
<name> |
string | Dotted module path to the domain (e.g. "identity.domain"). The key is the logical name used as the subdirectory. |