# Event versioning and contracts

Companion to `messaging-patterns` §4.

## Compatibility matrix

| Change to the payload | Compatible within `.vN`? | What to do |
|---|---|---|
| Add an optional field | Yes | Ship it; consumers must ignore unknown fields (System.Text.Json and Zod `z.object` do by default) |
| Add a value to an enum-like field | Yes, if documented as extensible | Consumers treat unknown values as "other"; never `switch` without a default |
| Make an optional field required | No | New version |
| Remove or rename a field | No | New version; keep the old name populated in `.vN` until retired |
| Change a type (`string` → `number`, date format) | No | New version |
| Change the meaning of a field (gross → net amount) | No, even if the type is unchanged | New version with a new field name |
| Change the partition key | No | New topic or new version; consumers assume ordering per key |
| Change the topic/queue name | No | New destination with dual-publish |

## Dual-publish recipe

1. Publish `orders.order-placed.v2` next to `.v1` from the same outbox transaction (two rows).
2. Migrate consumers one by one to `.v2`; each consumer's inbox key is `(message_id, consumer)`, so both versions can coexist without double effects only if consumers subscribe to **one** version at a time.
3. Watch consumption of `.v1` (consumer group lag, subscription metrics) until it is zero for a full retention period.
4. Stop producing `.v1`; delete the subscription after the retention window.

## Header set

| Header | Purpose |
|---|---|
| `message-id` | Dedupe key; equals the outbox row id |
| `message-type` | `orders.order-placed.v1` |
| `occurred-at` | RFC 3339 UTC, producer clock |
| `aggregate-id` / partition key | Ordering key |
| `correlation-id` | The business flow (order id, request id) |
| `causation-id` | The message or request that caused this one |
| `traceparent` | W3C trace context; propagated by OpenTelemetry instrumentation for most broker SDKs |
| `tenant-id` | Never inferred from the payload on the consumer side |
| `content-type` | `application/json`, `application/avro`, … |

## Schema publication options

| Option | Fits | Cost |
|---|---|---|
| JSON Schema files in the owner's `*.Contracts` package (NuGet or npm) | Modular monolith or few services in one org | Consumers must update the package to see changes |
| Schema registry (Confluent, Apicurio, Azure Schema Registry) with Avro/Protobuf | Kafka or Event Hubs, many consumers, compatibility rules enforced at publish time | Operate the registry; producers fail fast on incompatible schemas |
| AsyncAPI document per service | Documentation and generated consumer stubs | Keep it in CI like OpenAPI (see `api-design`) |

## Consumer tolerance rules

- Parse with a schema that **ignores unknown fields** and **rejects wrong types** (Zod: default `z.object` strips unknown keys; .NET: `JsonSerializerOptions` default ignores unmapped properties).
- Treat a schema-invalid message as a permanent failure: dead-letter it with the validation error; do not retry.
- Never depend on field order, on the absence of a field to mean "false", or on the producer's clock for business decisions.
