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 codes0/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 NULLtwo-step awareness (recognizes its own recommendedCHECK ... NOT VALID+VALIDATE CONSTRAINTpattern, including in raw SQL), volatile-default andGENERATED ... STOREDrewrites, 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 bothalter tableadds andreferences(...)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, andCREATE INDEX CONCURRENTLYattribute requirements (both the DSLconcurrently: trueform 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
TRUNCATEcarries 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 ... CONCURRENTLYis recognized),SET LOGGED/SET UNLOGGED(full rewrite under ACCESS EXCLUSIVE), andSET/DROP DEFAULT— all judged by the raw-DDL safety net instead of surfacing as genericunclassified_sqlwarnings. - 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
Unknownescape route, and a keyword SQL classifier with anunclassified_sqlescape 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_locksmodes, 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_checksconfig key registers modules implementing the publicCerbero.Checkbehaviour 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 getskip_checks,severity_overrides, and@cerbero_skiphandling like any built-in. - Aged-pending grace window tied to deploy cadence: the
deploy_cadenceconfig 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_magnitudeexport mode (config ormix 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 optionalprecisionfield to the snapshot (part of the format v1 baseline; absent means exact) and the check summary line notes the reduced precision. --format sarifonmix cerbero.check— SARIF 2.1.0 output for GitHub code-scanning annotations. Findings maperror/warning/notefrom 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-keymints an Ed25519 keypair,--sign-keysigns the export (signature over the checksum, which covers the canonical content), andsnapshot_verify_keysin.cerbero.exspins 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 optionalsignaturefield is part of the snapshot format v1 baseline. mix cerbero.check --downjudges rollback bodies:def downoperations and the down leg of two-argexecuteare 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
reposconfig key defines one{name, migrations_paths, snapshot_path}entry per Ecto repo. With no flag,mix cerbero.checkruns every repo and merges findings into one document (worst exit code wins; each repo's global findings anchor to its own snapshot artifact);--repo NAMEruns one. All other settings stay global, and explicit--migrations/--snapshotstill bypass the repo table. - CRDB support for the DBA path:
mix cerbero.snapshot --emit-sql --engine cockroachdbemits a CRDB-branched script (CRDB speaks pgwire and supports the sameCOPY (SELECT row_to_json(...)) TO STDOUTmechanism, verified on v25.1), and--from-filedetects 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_autoanalyzefor CockroachDB tables from statistics creation times (system.table_statistics, whatSHOW STATISTICSreads) — manualCREATE STATISTICSmaps to analyze, automatic__auto__collections to autoanalyze — so CRDB findings carry stats dates like PG findings do. Degrades to honestnilwhensystem.*is not readable. - Per-table stats-age
snapshot_healthfinding: an error-tier table targeted by the pending set whose statistics were already older thanstale_warn_daysat 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 ... DEFAULTvolatility detection for rule 3: a default opening with a function call or parenthesized expression (now(),random(),gen_random_uuid(),nextval(...)) now classifies asadd_column_volatile_defaultand 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 ... STOREDcolumn, 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.exspopulated 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--downfindings,no_snapshot: truein structural mode,skipped: {"via": ["migration_attribute"], "reason": ...}/skipped: {"via": ["config"]}for demoted findings (viais a list in application order, so a finding skipped through both routes keeps both provenances and the reason), plus the judgedlockeach rule declares — so JSON consumers can filter without parsing messages. Backward-compatible addition;cerbero_findings_versionstays 1. Human and SARIF outputs are unchanged. - Checks describe themselves: the
Cerbero.Checkbehaviour gained an optionaldescription/0callback (a one-line summary of what the check judges), implemented by every built-in check. SARIF ruleshortDescriptions now come from the check modules instead of a hardcoded catalog inside the formatter, so a third-partyextra_checksmodule exportingdescription/0gets 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_afterno longer silently drops migration files whose name carries no timestamp version. Pending selection existed twice — the no-snapshot path guardedversion != nilwhile the snapshot path'sversion <= start_afterterm-comparednilbelow 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 itselseswallowed: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 inSeverity.assess/6— the module that owns judgment policy — as an explicit:online_schema_changeclause shared by rules 1–3, andCheck.Helpers.crdb_cost_severity/3is gone. - The
lock_timeout_attestedannotation 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 missedfk_validation_scanfindings 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_overridesandskip_checksnow reachsnapshot_healthfindings. Those findings are produced outside the per-migration check runner (they judge the snapshot, not a migration), so the documentedseverity_overrides: %{snapshot_health: :error}andskip_checks: [:snapshot_health]were silently ignored for them. Per-migration@cerbero_skipstill does not apply to global findings — they belong to no single migration.mix cerbero.snapshotnow writes toconfig.snapshot_pathwhen--outis omitted, instead of a hardcodedpriv/repo/cerbero_snapshot.json. The two tasks previously disagreed on the path wheneversnapshot_pathwas customized: the exporter wrote one place andmix cerbero.checkread another (silently judging a stale or absent artifact), and on layouts with nopriv/repo/at the root — umbrella apps — the write crashed outright. Explicit--outstill overrides.
Known limitations
downbodies 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.