Skip to content

ADR-0001: Monotonic Integer Versioning for Events

Status: Accepted. Compatibility semantics are settled by the schema-evolution ladder in ADR-0040.

Amended November 2026, see the Consequences note on Protobuf field numbers.

Date: March 2026

Context

Events and commands in Protean are messages that a consumer may read long after a producer writes them. They are serialized, stored in event stores, transmitted through brokers, and consumed by handlers that may have been deployed at a different time than the producer. When the shape of an event changes (a field is added, renamed, or removed), consumers need to know which shape they're dealing with.

The question is how to version these messages. The two main candidates were semantic versioning (semver: 1.0.0, 1.1.0, 2.0.0) and monotonic integers (1, 2, 3).

Prior art in the event-sourcing ecosystem overwhelmingly uses simple integer versioning: EventStoreDB uses integer version numbers for stream positions and event types. Confluent's Schema Registry uses monotonic integer schema IDs. Apache Avro uses integer schema fingerprints. AWS EventBridge uses integer-versioned schemas. Marten and Axon both version events with simple integers. Semver is conspicuously absent from event versioning in production systems.

The reason is that semver's major/minor/patch semantics encode compatibility judgments into the version string itself, "1.1.0 is backward-compatible with 1.0.0 but 2.0.0 is not." This embeds a policy decision into what should be a simple identity marker.

Decision

We will version events and commands with monotonic positive integers. The __version__ attribute on message classes is an integer starting at 1, incremented by 1 for a change that needs a new version. The schema-evolution ladder (ADR-0040) lists which changes need a new version: a structural change bumps the version. A change that weak schema supports keeps the current version: a field added with a default, a rename via renamed_from, or a removed field read leniently. The exception is an event change that moves a Protobuf field number, which needs a new version. Upcasters apply to stored events: an upcaster transforms an old event payload to the new version, or the event becomes a new type when no value can be supplied. Commands are versioned the same way, but they are not replayed, so they carry no upcaster. The framework validates the __version__ integer at class creation time in BaseMessageType.__init_subclass__():

class UserRegistered(BaseEvent):
    __version__ = 1
    user_id: Identifier(identifier=True)
    email: String()

# After a structural change (email becomes required): bump the version.
# A v1 -> v2 upcaster supplies `email` for old payloads (registered separately).
class UserRegistered(BaseEvent):
    __version__ = 2
    user_id: Identifier(identifier=True)
    email: String(required=True)

The code block above shows only the change to the UserRegistered class. The v1 → v2 upcaster that supplies email for stored v1 payloads is registered separately, as the Event Upcasting guide shows.

The version appears in the message's __type__ string as {Domain}.{ClassName}.v{version} (e.g., Auth.UserRegistered.v2). Protean uses the __type__ string for runtime routing and event store lookups. The schema-evolution ladder (ADR-0040) defines the compatibility semantics: whether v2 is backward-compatible with v1, and how to convert between them. Under the ladder, each change goes to weak schema (renamed_from, lenient mode), an upcaster, or a new event type. The version number stays an identity marker.

Consequences

Versioning is simple and unambiguous. There is no debate about whether a change is a minor or patch version: the number increments by 1 when a change needs a new version, and ADR-0040 decides which changes do. Adding an optional or defaulted field, like renaming one, does not, unless the change moves a Protobuf field number of a field the event keeps (see the amendment to rung 1 in ADR-0040).

The __type__ string ({Domain}.{ClassName}.v{version}) is stable across code refactoring. Moving UserRegistered from auth.events to auth.domain.events does not change its __type__, so existing stored events remain routable. This is distinct from the FQN, which does change on refactoring.

The trade-off is that the version number alone tells you nothing about compatibility. Given UserRegistered v3, you cannot know from the number alone whether v1 events can be read as v3. You need to inspect the change and the evolution mechanism it uses, weak schema or an upcaster (the schema-evolution ladder in ADR-0040). Under Protean's guiding principles (ADR-0000, principle 8), compatibility analysis belongs to tooling. The Phase 4 compatibility checker will provide this analysis automatically.

Validation is strict: __version__ must be a positive integer. Strings, floats, zero, and negative numbers are rejected with IncorrectUsageError at class definition time, preventing accidental misuse.

Alternatives Considered

Semantic versioning was rejected because it embeds compatibility policy into the version format. In practice, determining whether an event schema change is "breaking" requires inspecting the actual field changes and the upcaster chain. This is the same work the tooling does regardless of version format. Semver adds complexity (three numbers to manage, rules about what constitutes major/minor/patch) without reducing the need for compatibility tooling.

String-based version tags (e.g., "v2-with-name-field") were considered for readability but rejected for three reasons. They don't support ordering, they make upcaster chain traversal ambiguous, and they introduce naming convention debates.