Evolving a conversation schema
A conversation schema is a contract between an unknown number of consumers: exporters,
warehouse models, dashboards, notebooks, scripts and anything someone built once and left
running. Changing it is easy; changing it without silently corrupting a historical series is
the skill.
The damage from a bad schema change is almost never an error. It is a metric that keeps
working and starts meaning something slightly different, discovered a quarter later when two
numbers disagree.
Classify the change first
| Change |
Breaking? |
Note |
| Adding an optional field |
No |
Safe, if consumers ignore unknown fields |
| Adding a required field |
Yes |
Old records lack it; every consumer must handle absence |
| Adding a value to an enum |
Yes in practice |
Consumers with exhaustive matches or lookup tables silently drop or mis-bucket the new value |
| Renaming a field |
Yes |
Never do it in place; add the new one, dual-write, migrate, remove |
| Changing a type |
Yes |
Including the innocuous ones: number to string, or a timestamp's precision |
| Narrowing a field |
Yes |
Making an optional field required, or shrinking an enum |
| Changing semantics with no structural change |
Yes, and the worst kind |
Nothing breaks; every historical comparison is now wrong |
That last row is the dangerous one and it has no compile-time signal. Redefining what
resolved_at means, or changing which conversations get channel = 'messaging', produces a
step change in every series built on it and no error anywhere.
Adding an enum value deserves more caution than it gets. It looks additive and it is
not: a consumer mapping five statuses to buckets will put the sixth nowhere, and a warehouse
CASE with no ELSE will return null. Treat any new enum value as breaking and tell
consumers.
Version explicitly, and put the version in the data
- Semantic versioning on the schema, with breaking changes reserved for a major bump.
- A
schema_version field on every record, not just in the documentation. A consumer
that can see the version can branch; one that cannot has to guess from field presence.
- Never reuse a field name with different meaning across versions. If the semantics
change, the field name changes.
- Keep the old version readable for as long as consumers need it, and know who they are —
which requires the consumer register below.
Dual-write is the mechanism that makes this safe
For anything breaking, the sequence is:
- Add the new field or value alongside the old, populated in parallel. Nothing consumes
it yet.
- Announce it, with a date, to the consumer register.
- Migrate consumers one at a time, verifying each.
- Verify nothing reads the old field — logs, query history, warehouse dependencies, and
the scripts nobody documented.
- Then remove it, and only then.
The step that gets skipped is 4, and it is the one that turns a clean migration into an
outage. "Nobody uses that" is a hypothesis; query logs are evidence.
For backfills, decide deliberately whether historical records get the new field. Both answers
are defensible and one must be documented: backfilled means the series is continuous and
history has been rewritten; not backfilled means history is honest and every consumer needs
to handle absence.
Keep the series comparable, or say that you did not
This is the part that matters to the people using the data rather than building it.
- A schema change that alters a metric's value breaks the series. Say so, on the
dashboard and in the metric definition, with the date.
- Annotate the change where the chart is read. A step in a chart with no annotation
produces an investigation; the same step with a note produces a shrug.
- Do not silently restate history. If you recompute historical values under the new
definition, label them restated and keep the original available.
- Do not silently leave history alone either. A series computed one way before a date and
another way after, with no marker, is the worst option and the most common.
Where a change is significant, publishing both definitions in parallel for a period is
expensive and lets consumers migrate on their own schedule.
Maintain a consumer register
You cannot manage a contract whose parties you do not know. The register is unglamorous and
it is what makes every decision above possible:
- Who reads each field, from which system, for what.
- Who to tell when it changes, and how much notice they need.
- Which consumers are outside your control — a customer's own pipeline, a partner's
integration, an exported spreadsheet on someone's laptop.
External consumers set your real deprecation window, and they are the ones absent from
every internal dependency graph.
Additive-only is a strategy, not a cop-out
For a schema with many uncontrolled consumers, deciding that you only ever add and never
change is a legitimate and often correct choice. The cost is accumulated cruft — deprecated
fields that stay forever — and the benefit is that nothing downstream ever breaks.
If you take it, mark deprecated fields as deprecated in the schema rather than removing
them, and stop populating them only after the register says nobody reads them.
Guardrails
- Do not change a field's meaning without changing its name. The single most damaging
move available.
- Do not remove a field because the current consumers do not use it. Check the query
history, and check outside your systems.
- Do not backfill in place without keeping the original. Backfills are frequently wrong
the first time, and an in-place backfill with no original is unrecoverable.
- Test the migration on a copy, including the rollback.
- A schema change that affects a published regulatory or contractual figure needs
disclosure, not just an annotation — route it rather than deciding it.
Present results to the user
- The change, classified, using the table above — including whether it is a
semantics-only change with no structural signal.
- The version bump, and whether
schema_version is present in the data.
- The consumer register for the affected fields, with the external consumers named
separately since they set the deprecation window.
- The dual-write sequence, with the "verify nothing reads the old field" step and the
evidence for it.
- The backfill decision, documented either way.
- Comparability impact — which metrics step, from which date, and how it will be
annotated where the chart is read.
- The rollback plan, tested.
- Anything requiring disclosure rather than annotation.
1---2name: cx-schema-evolution3description: Use to change a conversation or event schema without breaking every downstream metric, and to keep historical data comparable across the change. Trigger for "we need to add a field to the schema", "changing the conversation schema", "our historical data doesn't match after the schema change", schema versioning, breaking changes to an export format, or a metric that stepped when a field changed.4---56# Evolving a conversation schema78A conversation schema is a contract between an unknown number of consumers: exporters,9warehouse models, dashboards, notebooks, scripts and anything someone built once and left10running. Changing it is easy; changing it without silently corrupting a historical series is11the skill.1213**The damage from a bad schema change is almost never an error.** It is a metric that keeps14working and starts meaning something slightly different, discovered a quarter later when two15numbers disagree.1617## Classify the change first1819| Change | Breaking? | Note |20| --- | --- | --- |21| Adding an optional field | No | Safe, if consumers ignore unknown fields |22| Adding a required field | **Yes** | Old records lack it; every consumer must handle absence |23| Adding a value to an enum | **Yes in practice** | Consumers with exhaustive matches or lookup tables silently drop or mis-bucket the new value |24| Renaming a field | **Yes** | Never do it in place; add the new one, dual-write, migrate, remove |25| Changing a type | **Yes** | Including the innocuous ones: number to string, or a timestamp's precision |26| Narrowing a field | **Yes** | Making an optional field required, or shrinking an enum |27| Changing semantics with no structural change | **Yes, and the worst kind** | Nothing breaks; every historical comparison is now wrong |2829That last row is the dangerous one and it has no compile-time signal. Redefining what30`resolved_at` means, or changing which conversations get `channel = 'messaging'`, produces a31step change in every series built on it and no error anywhere.3233**Adding an enum value deserves more caution than it gets.** It looks additive and it is34not: a consumer mapping five statuses to buckets will put the sixth nowhere, and a warehouse35`CASE` with no `ELSE` will return null. Treat any new enum value as breaking and tell36consumers.3738## Version explicitly, and put the version in the data3940- **Semantic versioning on the schema**, with breaking changes reserved for a major bump.41- **A `schema_version` field on every record**, not just in the documentation. A consumer42 that can see the version can branch; one that cannot has to guess from field presence.43- **Never reuse a field name with different meaning across versions.** If the semantics44 change, the field name changes.45- **Keep the old version readable for as long as consumers need it**, and know who they are —46 which requires the consumer register below.4748## Dual-write is the mechanism that makes this safe4950For anything breaking, the sequence is:51521. **Add the new field or value alongside the old**, populated in parallel. Nothing consumes53 it yet.542. **Announce it**, with a date, to the consumer register.553. **Migrate consumers** one at a time, verifying each.564. **Verify nothing reads the old field** — logs, query history, warehouse dependencies, and57 the scripts nobody documented.585. **Then remove it**, and only then.5960The step that gets skipped is 4, and it is the one that turns a clean migration into an61outage. "Nobody uses that" is a hypothesis; query logs are evidence.6263For backfills, decide deliberately whether historical records get the new field. Both answers64are defensible and one must be documented: backfilled means the series is continuous and65history has been rewritten; not backfilled means history is honest and every consumer needs66to handle absence.6768## Keep the series comparable, or say that you did not6970This is the part that matters to the people using the data rather than building it.7172- **A schema change that alters a metric's value breaks the series.** Say so, on the73 dashboard and in the metric definition, with the date.74- **Annotate the change where the chart is read.** A step in a chart with no annotation75 produces an investigation; the same step with a note produces a shrug.76- **Do not silently restate history.** If you recompute historical values under the new77 definition, label them restated and keep the original available.78- **Do not silently leave history alone either.** A series computed one way before a date and79 another way after, with no marker, is the worst option and the most common.8081Where a change is significant, publishing both definitions in parallel for a period is82expensive and lets consumers migrate on their own schedule.8384## Maintain a consumer register8586You cannot manage a contract whose parties you do not know. The register is unglamorous and87it is what makes every decision above possible:8889- Who reads each field, from which system, for what.90- Who to tell when it changes, and how much notice they need.91- Which consumers are outside your control — a customer's own pipeline, a partner's92 integration, an exported spreadsheet on someone's laptop.9394**External consumers set your real deprecation window**, and they are the ones absent from95every internal dependency graph.9697## Additive-only is a strategy, not a cop-out9899For a schema with many uncontrolled consumers, deciding that you only ever add and never100change is a legitimate and often correct choice. The cost is accumulated cruft — deprecated101fields that stay forever — and the benefit is that nothing downstream ever breaks.102103If you take it, mark deprecated fields as deprecated **in the schema** rather than removing104them, and stop populating them only after the register says nobody reads them.105106## Guardrails107108- **Do not change a field's meaning without changing its name.** The single most damaging109 move available.110- **Do not remove a field because the current consumers do not use it.** Check the query111 history, and check outside your systems.112- **Do not backfill in place without keeping the original.** Backfills are frequently wrong113 the first time, and an in-place backfill with no original is unrecoverable.114- **Test the migration on a copy**, including the rollback.115- **A schema change that affects a published regulatory or contractual figure** needs116 disclosure, not just an annotation — route it rather than deciding it.117118## Present results to the user1191201. **The change, classified**, using the table above — including whether it is a121 semantics-only change with no structural signal.1222. **The version bump**, and whether `schema_version` is present in the data.1233. **The consumer register** for the affected fields, with the external consumers named124 separately since they set the deprecation window.1254. **The dual-write sequence**, with the "verify nothing reads the old field" step and the126 evidence for it.1275. **The backfill decision**, documented either way.1286. **Comparability impact** — which metrics step, from which date, and how it will be129 annotated where the chart is read.1307. **The rollback plan**, tested.1318. **Anything requiring disclosure** rather than annotation.