Compatibility Checking
Diff and check schema versions; gate breaking changes in CI.
Once other teams depend on your schema, every change risks breaking them. OpenSchema compares two versions of a schema, classifies every change, and tells you whether the new version is compatible with the old one — so you can gate schema changes in CI.
Two commands:
diff— show every change between two schemas.check— assert compatibility under a mode; exit non-zero if violated.
A first diff
Given order.v1.schema:
model Order {
1 id: uuid
2 customerName: string
3 total: i32
}
and order.v2.schema:
model Order {
1 id: uuid
2 customer: string // renamed (ordinal 2 unchanged)
3 total: i64 // widened i32 -> i64
4 note: string? // new nullable field
}
openschema diff order.v1.schema order.v2.schema
Diff: order.v1.schema → order.v2.schema
Warnings
⚠ [R005] Order.customer warning
Safe
✓ [R006] Order.total safe
✓ [R001] Order.note safe
Summary: 1 warnings, 2 safe
Notice the rename was detected as a rename (R005), not as "removed
customerName, added customer." That is the payoff of
ordinals: field 2 is the same
field, just relabeled.
Severities
Every change is classified by who it can break. "Reader" and "writer" are from the perspective of application code, not schema versions:
| Severity | Meaning |
|---|---|
safe |
No impact on any existing code or data. |
warning |
Technically safe, but worth a human's eyes (e.g. a rename). |
breaking_reader |
Code that reads this data may break (a field it expects is gone). |
breaking_writer |
Code that writes this data may break (a new requirement it can't meet). |
breaking_both |
Breaks readers and writers. |
Compatibility modes
check enforces one of four modes:
| Mode | Question it answers | Violated by |
|---|---|---|
backward |
Can the new schema read data written by the old one? | breaking_reader, breaking_both |
forward |
Can the old schema read data written by the new one? | breaking_writer, breaking_both |
full |
Both of the above. | any breaking_* |
none |
No enforcement. | nothing |
Backward compatibility is the most common requirement: you deploy a new schema and it must still understand the data already in the database.
openschema check order.v1.schema order.v2.schema --mode backward
✓ Compatible under backward mode (3 changes, none breaking)
Exit code 0. If a change had violated the mode, check prints the violations
with explanations and exits 1 — ready to fail a CI job.
# In CI:
openschema check schema.published.schema schema.proposed.schema --mode backward || exit 1
The rules
Each change kind has a stable rule ID, so you can reference it (and suppress it).
Models and fields
| Rule | Change | Severity |
|---|---|---|
R001 |
nullable field added | safe |
R002 |
required field added with @default |
safe |
R004 |
field removed | breaking_reader |
R005 |
field renamed (same ordinal) | warning |
R006 |
field type widened (e.g. i32→i64) |
safe |
R007 |
field type narrowed | breaking_writer |
R008 |
field type changed incompatibly | breaking_both |
R009 |
field made nullable | safe |
R010 |
required field added (no default) | breaking_writer |
R011 |
field made non-nullable | breaking_writer |
R012 |
@unique added |
breaking_writer |
R013 |
@check added |
breaking_writer |
R014 |
@unique/@check removed |
safe |
R015 |
@primaryKey added or removed |
breaking_both |
R016 |
model added | safe |
R017 |
model removed | breaking_both |
R018 |
@references target changed |
breaking_both |
R019 |
field marked @deprecated |
warning |
R020 |
model renamed (via @renamed) |
warning |
Enums
| Rule | Change | Severity |
|---|---|---|
E001 |
variant added | warning |
E002 |
variant removed | breaking_reader |
E003 |
variant renamed (same ordinal) | warning |
E004 |
variant ordinal changed (renumbered) | breaking_both |
E005 |
enum added | safe |
E006 |
enum removed | breaking_both |
E007 |
enum renamed (via @renamed) |
warning |
E004 is the dangerous one: renumbering a variant silently reinterprets every
piece of data that used the old number. Keep variant ordinals stable, like field
ordinals.
Type aliases
| Rule | Change | Severity |
|---|---|---|
T001 |
alias target changed | mirrors the underlying type change |
T002 |
alias added | safe |
T003 |
alias removed | breaking_both |
T004 |
alias renamed (via @renamed) |
warning |
Renaming a model, enum, or alias
Fields and enum variants are matched by ordinal, so renaming one is already a warning — the ordinal preserves identity. Declarations have no ordinal; they are matched by name, so renaming a model normally looks like a breaking remove + add.
Carry the old name forward with @renamed("OldName") and the differ matches the
new declaration to the removed one, reporting a rename (R020/E007/T004,
warning) instead. Fields are still diffed under the new name.
// was: model Order { ... }
@renamed("Order")
model PurchaseOrder {
1 id: uuid
2 total: i32
}
Drop the decorator once every consumer has migrated past the rename. If a
declaration with the old name still exists, the @renamed is ignored and the
new declaration counts as a normal addition.
Suppressing a rule
Sometimes a flagged change is intentional and you want to silence it for a
specific element. Use a #suppress directive naming the rule and a reason:
model User {
// Renaming this is intentional; we've migrated all consumers.
#suppress "R005" "renamed fullName -> name in v3, consumers migrated"
1 name: string
}
A #suppress on a field suppresses matching changes for that field; on a
model or enum, for that declaration. The reason string is required — it
documents why the rule was waived, for the next person who reads the diff.
How it fits your workflow
- Keep the last published schema in version control.
- On every pull request that touches the schema, run
openschema check <published> <proposed> --mode backwardin CI. - A red check means a breaking change — either rework it, bump a major version,
or
#suppressthe specific rule with a justification.
This turns "did we break a downstream consumer?" from a question you answer in production into one CI answers before merge.