All notable changes to this project are documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

v0.1.0 (2026-08-11)

Initial release: cerbero detects a specific catalog-derivable class of unsafe Ecto migrations, judged at export-time scale, for PostgreSQL and CockroachDB. It does not certify migrations as safe; it judges the statement, not the moment.

Added

  • mix cerbero.snapshot — exports a privacy-bounded snapshot of database catalog metadata (schema shapes, row/byte estimates, traffic counters, index/constraint validity) as a canonical, checksummed, human-diffable JSON artifact. Includes a DBA-mediated path (--emit-sql / --from-file) so the exporter's allowlisted queries can be reviewed and run with a DBA's own credentials.
  • mix cerbero.check — judges pending migrations against the committed snapshot offline (no database reachable from CI), with exit codes 0/1/2, human and JSON output, golden-tested formatting, and a no-snapshot structural mode (--no-snapshot) as a zero-friction trial path.
  • Ten checks: non-concurrent index creation (partition-aware, with the per-partition recipe), SET NOT NULL two-step awareness (recognizes its own recommended CHECK ... NOT VALID + VALIDATE CONSTRAINT pattern, including in raw SQL), volatile-default and GENERATED ... STORED rewrites, catalog-aware column type changes (binary-coercible pairs stay quiet; index rebuilds are named), foreign-key validation scans (both tables' scale; the referenced table's blocked writes are called out), missing FK indexes (covering both alter table adds and references(...) declared inside the create block; primary-key columns count as covered), CockroachDB transactional-DDL restrictions, snapshot health (staleness, invalid indexes, history divergence, absent tables), unbatched DML at scale, and CREATE INDEX CONCURRENTLY attribute requirements (both the DSL concurrently: true form and raw-SQL CIC — including the raw per-partition CIC that rule 1's own partitioned-parent remediation recommends).
  • A raw-DDL safety net: classified raw SQL that no named rule owns is still judged by lock mode and cost — an ACCESS EXCLUSIVE-taking operation is never silent, and TRUNCATE carries an error-severity floor.
  • Classifier patterns for the remaining raw DDL: RENAME (table, column, constraint), ATTACH/DETACH PARTITION (the attach validation scan is charged to the attached partition; DETACH ... CONCURRENTLY is recognized), SET LOGGED/SET UNLOGGED (full rewrite under ACCESS EXCLUSIVE), and SET/DROP DEFAULT — all judged by the raw-DDL safety net instead of surfacing as generic unclassified_sql warnings.
  • Severity that tracks reality: row/byte tiers, traffic-aware lock-queue gating, staleness headroom (thresholds shrink as the snapshot ages), and degradation to unknown-is-unbounded past the configured age. Unknown scale is never treated as small.
  • Static AST migration parsing (user migration code is never executed) with an explicit Unknown escape route, and a keyword SQL classifier with an unclassified_sql escape route — both reachable by --fail-on.
  • A lock/cost table verified empirically: the integration suite executes representative DDL against live PostgreSQL 13 and 16 and asserts the acquired pg_locks modes, plus a CockroachDB differential against v25.1.
  • Per-migration escape hatch (@cerbero_skip, reason required, findings still shown) and configuration via .cerbero.exs (thresholds, headroom, staleness ages, severity overrides, strict_concurrent_index, lock_timeout_attested, schemas, paths).
  • Third-party check registration: the extra_checks config key registers modules implementing the public Cerbero.Check behaviour into the runner (validated at config load; additive only — built-ins are never displaced, and a built-in listed there is not run twice). Registered checks get skip_checks, severity_overrides, and @cerbero_skip handling like any built-in.
  • Aged-pending grace window tied to deploy cadence: the deploy_cadence config key (days, default 1) keeps the snapshot-health heuristic from flagging every pending migration merely older than a nightly-refreshed snapshot; only migrations predating it by more than one deploy cycle warn.
  • Opt-in precision: :order_of_magnitude export mode (config or mix cerbero.snapshot --precision): buckets every exported count and byte to its power-of-ten floor so committed snapshots do not reveal exact business metrics; the default row tiers are powers of ten, so verdicts survive. Adds the optional precision field to the snapshot (part of the format v1 baseline; absent means exact) and the check summary line notes the reduced precision.
  • --format sarif on mix cerbero.check — SARIF 2.1.0 output for GitHub code-scanning annotations. Findings map error/warning/note from their severities; global snapshot-health findings anchor to the committed snapshot file so they still surface in PR review.
  • Cryptographic snapshot signing: mix cerbero.snapshot --gen-signing-key mints an Ed25519 keypair, --sign-key signs the export (signature over the checksum, which covers the canonical content), and snapshot_verify_keys in .cerbero.exs pins the trusted public keys — once set, an unsigned, tampered-and-restamped, or foreign-key-signed snapshot refuses to load. Unsigned snapshots without pinned keys behave exactly as before. The optional signature field is part of the snapshot format v1 baseline.
  • mix cerbero.check --down judges rollback bodies: def down operations and the down leg of two-arg execute are parsed into their own operation list and judged against the catalog as the pending ups leave it (the state a rollback starts from), with findings labeled [down]. Off by default — deploy-direction output is unchanged without the flag.
  • Multi-repo configuration for umbrella apps: a repos config key defines one {name, migrations_paths, snapshot_path} entry per Ecto repo. With no flag, mix cerbero.check runs every repo and merges findings into one document (worst exit code wins; each repo's global findings anchor to its own snapshot artifact); --repo NAME runs one. All other settings stay global, and explicit --migrations/--snapshot still bypass the repo table.
  • CRDB support for the DBA path: mix cerbero.snapshot --emit-sql --engine cockroachdb emits a CRDB-branched script (CRDB speaks pgwire and supports the same COPY (SELECT row_to_json(...)) TO STDOUT mechanism, verified on v25.1), and --from-file detects the engine from the file's own sections — including the crdb row-count and stats-timestamp sections — with no out-of-band flag.
  • Layer-4 lock verification now asserts the mapped lock is the strongest mode held on the target relation, not merely among the held modes, so an over-locking regression can no longer hide behind a weaker mapped entry. Verified green against live PG 13 and PG 16.
  • CRDB analyze-timestamp equivalent: the exporter now fills last_analyze/last_autoanalyze for CockroachDB tables from statistics creation times (system.table_statistics, what SHOW STATISTICS reads) — manual CREATE STATISTICS maps to analyze, automatic __auto__ collections to autoanalyze — so CRDB findings carry stats dates like PG findings do. Degrades to honest nil when system.* is not readable.
  • Per-table stats-age snapshot_health finding: an error-tier table targeted by the pending set whose statistics were already older than stale_warn_days at export (or never analyzed) now gets an explicit low-confidence warning, instead of only an old date inside other rules' messages. Standby snapshots keep their single standby warning.
  • Raw-SQL ADD COLUMN ... DEFAULT volatility detection for rule 3: a default opening with a function call or parenthesized expression (now(), random(), gen_random_uuid(), nextval(...)) now classifies as add_column_volatile_default and gets the rewrite warning, mirroring the exporter's literal-vs-expression honesty line; literals and casts stay metadata-only.
  • CRDB type changes now name the real remaining rejection: when the table has a separate GENERATED ... STORED column, the warning cites the dependent-generated-column mechanism (SQLSTATE 2BP01) and its drop/re-add remediation instead of only the generic transaction restriction.
  • mix cerbero.gen.config — writes a .cerbero.exs populated with the built-in defaults, every setting visible and commented; deleting a line falls back to the same default. Refuses to overwrite without --force.
  • Structured provenance in JSON output: each finding now carries a "metadata" object (empty {} when there is none) recording as data what the message strings already say in prose — direction: "down" for --down findings, no_snapshot: true in structural mode, skipped: {"via": ["migration_attribute"], "reason": ...} / skipped: {"via": ["config"]} for demoted findings (via is a list in application order, so a finding skipped through both routes keeps both provenances and the reason), plus the judged lock each rule declares — so JSON consumers can filter without parsing messages. Backward-compatible addition; cerbero_findings_version stays 1. Human and SARIF outputs are unchanged.
  • Checks describe themselves: the Cerbero.Check behaviour gained an optional description/0 callback (a one-line summary of what the check judges), implemented by every built-in check. SARIF rule shortDescriptions now come from the check modules instead of a hardcoded catalog inside the formatter, so a third-party extra_checks module exporting description/0 gets its description in SARIF output rather than degrading to its bare id (which remains the fallback for checks without one). SARIF bytes for built-in checks are unchanged.

Fixed

  • Snapshot mode with start_after no longer silently drops migration files whose name carries no timestamp version. Pending selection existed twice — the no-snapshot path guarded version != nil while the snapshot path's version <= start_after term-compared nil below any string, so the same unversioned file was judged in one mode and dropped in the other. Both modes now share one policy (Runner.split_pending/3): a nil-version migration is always pending, always judged.
  • Non-concurrent index builds on a CockroachDB table with unknown scale now warn instead of passing silently. Rule 1's CRDB cost branch pattern-matched on {:rows, ...} and its else swallowed :unknown — a direct violation of the "unknown scale is unbounded, never small" doctrine (rules 2 and 3 already warned on the same input). The CRDB online-schema-change cost tier now lives in Severity.assess/6 — the module that owns judgment policy — as an explicit :online_schema_change clause shared by rules 1–3, and Check.Helpers.crdb_cost_severity/3 is gone.
  • The lock_timeout_attested annotation now keys on structured lock metadata (Finding.metadata.lock, declared by each rule) instead of matching finding message text for "lock_timeout" / "ACCESS EXCLUSIVE". The string match missed fk_validation_scan findings entirely — their SHARE ROW EXCLUSIVE messages contained neither string — so attested teams never saw the annotation there, and any wording edit could silently flip attestation.
  • severity_overrides and skip_checks now reach snapshot_health findings. Those findings are produced outside the per-migration check runner (they judge the snapshot, not a migration), so the documented severity_overrides: %{snapshot_health: :error} and skip_checks: [:snapshot_health] were silently ignored for them. Per-migration @cerbero_skip still does not apply to global findings — they belong to no single migration.
  • mix cerbero.snapshot now writes to config.snapshot_path when --out is omitted, instead of a hardcoded priv/repo/cerbero_snapshot.json. The two tasks previously disagreed on the path whenever snapshot_path was customized: the exporter wrote one place and mix cerbero.check read another (silently judging a stale or absent artifact), and on layouts with no priv/repo/ at the root — umbrella apps — the write crashed outright. Explicit --out still overrides.

Known limitations

  • down bodies are judged only on request (--down); rollback judgments use the post-up catalog state in version order, not a one-at-a-time unwind.
  • A snapshot is point-in-time: pending vs. applied-after-snapshot is offline-indistinguishable; scheduled re-export is the real mitigation.