Compatibility checking
Protean's IR (Intermediate Representation) tooling helps you detect breaking
changes to your domain model before they reach production. This guide walks
you through setting up the .protean/ directory, configuring pre-commit
hooks, and adding compatibility checks to CI.
For the classification rules that determine what counts as a breaking change, see the Compatibility Reference.
Step 1: Materialize the IR baseline
The .protean/ directory holds your materialized IR snapshot. The baseline
that changes are compared against. The easiest way to create it is with the
pre-commit hook's --fix flag (see Step 3),
which auto-creates the directory and generates the IR on every commit.
To generate the baseline manually, use --canonical so the snapshot omits
the volatile generated_at timestamp:
protean ir show --domain myapp.domain --canonical > .protean/ir.json
Commit .protean/ir.json to version control. It serves as the baseline for
detecting changes between releases. Because a canonical baseline excludes the
materialization timestamp, regenerating it produces a git diff only when
the domain contract actually changes, keeping baseline diffs reviewable instead
of drowning in per-file timestamp churn. The --fix staleness hook writes the same
canonical output.
Multi-domain projects
Projects with multiple bounded contexts use a subdirectory per domain:
.protean/
├── config.toml # Shared configuration (includes [domains] table)
├── identity/
│ └── ir.json
├── catalogue/
│ └── ir.json
└── ordering/
└── ir.json
Configure the [domains] table in .protean/config.toml:
[domains]
identity = "identity.domain"
catalogue = "catalogue.domain"
ordering = "ordering.domain"
Step 2: Configure strictness
Create .protean/config.toml to customize behavior. All settings are
optional, sensible defaults apply when the file is absent:
[compatibility]
strictness = "strict" # "strict" | "warn" | "off"
exclude = ["myapp.internal.LegacyEvent"]
[compatibility.deprecation]
min_versions_before_removal = 3
[staleness]
enabled = true
For the full list of configuration keys, see the config reference.
Step 3: Add pre-commit hooks
Protean ships two pre-commit hooks. Add them to
your project's .pre-commit-config.yaml:
repos:
- repo: local
hooks:
- id: protean-check-staleness
name: Check IR staleness
entry: uv run protean-check-staleness --domain=myapp.domain
language: system
pass_filenames: false
always_run: true
- id: protean-check-compat
name: Check IR compatibility
entry: uv run protean-check-compat --domain=myapp.domain
language: system
pass_filenames: false
always_run: true
Why repo: local?
Protean hooks call derive_domain() which imports your application's
domain modules. A remote repo: installs hooks in an isolated virtualenv
that does not have access to your source code, so the import will fail.
Using repo: local with language: system runs the hook in the caller's
environment. Prefix the entry with uv run (or activate your virtualenv)
to ensure the hook executes inside your project's environment where your
code is importable.
protean-check-staleness
Blocks the commit if .protean/ir.json is out of date.
Without --fix, a stale check prints the mismatch and suggests a manual
regeneration command. With --fix, the hook regenerates the IR, stages the
file with git add, and exits 0, allowing the commit to proceed.
--fix writes the canonical baseline (sorted keys, no volatile
generated_at), byte-identical to protean ir show --canonical. So it is safe
to use alongside a committed canonical baseline: the two never fight, and a
regeneration produces a diff only when the domain contract actually changes.
# Auto-fix mode -- never blocks on stale IR
repos:
- repo: local
hooks:
- id: protean-check-staleness
name: Check IR staleness
entry: uv run protean-check-staleness --domain=myapp.domain --fix
language: system
pass_filenames: false
always_run: true
The IR_STALE diagnostic
protean.ir.staleness.staleness_diagnostic() turns a stale StalenessResult
into a coded diagnostic in Protean's shared shape. Alongside the stable code,
rationale, and prose fix, the IR_STALE diagnostic names the command that
clears it, its resolving operation (the rule and suggestion text is
abbreviated below):
{
"code": "IR_STALE",
"category": "versioning",
"level": "warning",
"element": "myapp.domain",
"message": "Materialized IR for myapp.domain is stale. The domain has changed since it was last generated.",
"rule": { "rationale": "...", "fix": "Regenerate the baseline with `protean-check-staleness --fix` ..." },
"suggestion": "Regenerate the baseline with `protean-check-staleness --fix` ...",
"resolving_operation": {
"command": "protean-check-staleness",
"args": ["--fix"],
"display": "protean-check-staleness --fix"
}
}
resolving_operation is the structured form an agent runs directly: command
plus args, supplying its own --domain/--dir context, with display the
same command as a human would read it. Running it regenerates the baseline and
clears the diagnostic. A diagnostic whose failure no command resolves omits the
field, and the prose fix stands.
protean-check-compat
Blocks the commit if breaking IR changes are detected against the baseline
in HEAD.
Multi-domain support
When your config.toml has a [domains] table, omit the --domain
argument. Both hooks iterate over all configured domains automatically:
# No --domain needed -- reads [domains] from .protean/config.toml
repos:
- repo: local
hooks:
- id: protean-check-staleness
name: Check IR staleness
entry: uv run protean-check-staleness --fix
language: system
pass_filenames: false
always_run: true
- id: protean-check-compat
name: Check IR compatibility
entry: uv run protean-check-compat
language: system
pass_filenames: false
always_run: true
Each domain's IR is checked against its own subdirectory
(.protean/<name>/ir.json). The hooks exit non-zero if any domain fails
its check.
Step 4: Add CI checks
GitHub Actions
Add a compatibility check step to your CI workflow:
- name: Check IR compatibility
run: |
protean ir diff --domain myapp.domain --base origin/main
The command exits with code 1 on breaking changes, which fails the CI step.
pytest warning filters
Turn Protean deprecation warnings into test failures:
# pyproject.toml
[tool.pytest.ini_options]
filterwarnings = [
"error::DeprecationWarning:protean.*",
]
This catches deprecated API usage during development rather than after a breaking release.
Using the CLI
protean ir check
Compare the live domain against the materialized IR:
protean ir check --domain myapp.domain
Exit codes: 0 (fresh), 1 (stale), 2 (no IR found).
protean ir diff
Compare two IR snapshots with full breaking-change classification:
# Auto-baseline: compare live domain against .protean/ir.json
protean ir diff --domain myapp.domain
# Compare against a specific git commit
protean ir diff --domain myapp.domain --base HEAD
# Compare two explicit files
protean ir diff --left baseline.json --right current.json
Exit codes: 0 (no changes), 1 (breaking changes), 2 (non-breaking only).
When strictness = "warn", breaking changes are reported but the exit code
is 0. When strictness = "off", the command exits 0 immediately.
Avro compatibility verdict
ir diff also reports an Avro-style verdict (BACKWARD, FORWARD,
FULL, or NONE) matching the rules a schema registry applies to the Avro
that protean schema generate --format avro emits (a declared rename, for
instance, emits Avro aliases so it reads as BACKWARD):
BACKWARD: A consumer on the new schema reads old-written data (safe: delete a field, add an optional field).FORWARD: A consumer on the old schema reads new-written data (safe: add a field, delete an optional field).FULL: Both;NONE: neither.
Avro compatibility: FORWARD
breaks BACKWARD: Required field 'amount' added to EVENT 'app.OrderPlaced' without a default value
The verdict folds every change together: adding an optional field is FULL;
adding a required field with no default is not BACKWARD; removing a required
field is not FORWARD; a type change is NONE. A registered upcaster that
covers the version bump makes an otherwise-incompatible change BACKWARD, a
Protean-specific rule, since Protean rewrites old payloads at read time and
a plain schema registry (which knows nothing of upcasters) would still report
the underlying change. Visibility flips are payload-neutral for the verdict but
still count as breaking changes.
The verdict is informational. It is printed (and, under --format json,
included in a compatibility block with the full classified report), but the
exit code stays governed by strictness and the breaking-change
classification.
For the full CLI reference, see protean ir.
See also
- Compatibility Reference: Breaking change rules, three-tier taxonomy, deprecation lifecycle, and config key reference.
protean irCLI Reference: Full CLI command documentation.- ADR-0004: Release workflow and breaking change policy.