A Kafka publisher renaming `email` to `emailAddress` in the event payload. Three downstream consumers still read `email`. Mesrai flagged the cross-service contract break.
The vulnerable diff
// events/user-created.ts
- publish("user.created", { id: user.id, email: user.email });
+ publish("user.created", { id: user.id, emailAddress: user.email });
// downstream services still reading event.email:
// - services/welcome-emailer: reads event.email → undefined
// - services/crm-sync: reads event.email → undefined
// - services/marketing-bus: reads event.email → undefinedWhat is wrong
Events are a distributed-system API contract. Renaming a field is a breaking change for every consumer. Unlike a synchronous HTTP API, you can't roll back a producer change after consumers have processed old events. The standard mitigations are: add a `v` (version) field to every event, keep both old and new field names during a transition window, and remove the old name only after telemetry confirms no consumer reads it. The discipline maps to OpenAPI Deprecation but for async events.
The attack
Production timeline without versioning:
t+0 — Producer deploys with renamed field.
t+0 — welcome-emailer reads event.email = undefined → fails to send.
t+5min — crm-sync nulls out customer emails.
t+15min — Incident. Rollback producer.
t+1h — Producer rolled back. crm-sync data still corrupted.
t+1d — Hand-rolled migration to repair crm-sync records.Async contracts demand more discipline than sync — there is no synchronous error to alert on, and rollback doesn't undo consumer side effects.
Mesrai's review comment
mesraipilot · Bot · reviewed 2 min ago
[mesrai] [code-review] [Logic] [Schema-Evolution] [critical]
Event payload field rename without versioning. Three known consumers
still read `event.email`:
- services/welcome-emailer/handler.ts:18
- services/crm-sync/sync.ts:42
- services/marketing-bus/dispatch.ts:67
Phase the change:
1. (This PR) Producer publishes BOTH names, bumps version:
publish("user.created", {
v: 2,
id: user.id,
email: user.email, // legacy, keep for now
emailAddress: user.email, // new
});
2. Migrate each consumer to read `emailAddress`. Separate PRs per
service, behind feature flags if needed.
3. After all consumers are migrated AND telemetry confirms zero
reads of `email`, remove the legacy field in a follow-up PR.
For new schemas, prefer a schema-registry (Confluent Schema Registry,
Apicurio) that enforces compatibility rules on every produce.The fix
// events/user-created.ts — fixed (transition phase)
publish("user.created", {
v: 2,
id: user.id,
email: user.email, // legacy name — keep until consumers migrate
emailAddress: user.email, // new canonical name
});Both names in the payload during transition. `v: 2` lets consumers detect the new shape. Follow-up PR removes legacy after telemetry confirms no consumer reads it.
Why human review missed it
Event-schema discipline is missing from most teams without a schema registry. The bug surfaces only after producer deploy when consumers hit the old field. Mesrai cross-references publish calls against consumer code in the monorepo and flags renames that would break known consumers.
Related rules + further reading
Mesrai rule pack: logic/event-payload-versioning — flags event payload renames without backward compatibility.
Confluent Schema Registry — compatibility modes (BACKWARD, FORWARD, FULL).
Common in microservice architectures without a registry.
Takeaway
Event renames need backward compat. Keep both names. Migrate consumers. Remove old name last. Mesrai catches the break.