Modules
Offline safety checks for Ecto migrations, judged against a committed snapshot of production catalog metadata (Postgres and CockroachDB).
argv -> findings -> formatted output -> exit code. Injectable clock and IO.
Severity-first human output: findings are grouped into an errors section,
then warnings, then (only with --verbose) informational notes, each
section headed by a count so the reader knows what to fix first. Color is
applied via IO.ANSI and disabled automatically when output is not a
terminal or NO_COLOR is set, so piped/CI logs stay plain and the golden
tests stay stable.
Stable, versioned, canonically-encoded JSON output.
SARIF 2.1.0 output: a mechanical adapter over the findings list for GitHub code-scanning annotations. Global findings (no file) anchor to the committed snapshot artifact — the file they are actually about.
argv for mix cerbero.gen.config: --out (default .cerbero.exs), --force.
argv for mix cerbero.snapshot: --url | --emit-sql | --from-file, --out, --config, --migration-source, --precision, --engine (postgres | cockroachdb; steers --emit-sql only — the live path detects the engine from the connection and --from-file from the file's own sections).
The queryable in-memory model the checks run against. Row-estimate policy: max(reltuples, n_live_tup) when reltuples >= 0, else n_live_tup; partitioned parents are the sum of their partitions, never the parent row; unknown scale is unbounded, never small.
Behaviour for migration checks. Internal rules are its first consumers; it is public API by design (spec constraint, born from real Credo-check pain).
Rule 7: CockroachDB transactional schema-change restrictions.
Rule 3: volatile defaults and GENERATED STORED columns rewrite the table.
Rule 4: type changes — rewrite + index rebuilds on PG; engine-rejection table on CRDB.
Rule 10: concurrently: true without both @disable_ddl_transaction and @disable_migration_lock fails at deploy time and leaves an invalid index. Rule 1's own advice must not produce that.
Rule 9: classifier-detected UPDATE/DELETE/INSERT..SELECT against a table above threshold.
Rule 6: a new FK whose referencing column has no covering index in catalog ∪ overlay.
Rule 5: ADD FK scans the referencing table while blocking writes on BOTH tables.
Shared rule scaffolding: the migration-local fold, message construction, finding assembly.
The shared judgment spine every scale-gated rule walks:
born-silence -> scale/traffic -> Cerbero.Severity.assess/6 -> finding
assembly (default relations, judged-lock metadata).
The escape routes for the worst migrations must be reachable by --fail-on: unclassified SQL, unknown operations, and unmapped lock entries all warn by default, never silence.
Rule 2: SET NOT NULL scans under AEL unless a validated IS NOT NULL CHECK exists (PG >= 12).
Catch-all for classified raw SQL whose class no other rule consumes.
Orders pending migrations, threads the overlay, applies skips and severity overrides.
Rule 8: the snapshot's own health, surfaced as findings — never silent decay, never exit 2. Staleness degrades confidence; absence is never safety.
Rule 1: non-concurrent create/drop index. Severity scales with size and traffic.
Checker configuration, loaded from .cerbero.exs (a keyword list).
CockroachDB limitation table, keyed by class and version. Same data-not-conditionals rigor as Locks. Scoped to the facts rules 4 and 7 consume; layer 4 asserts the observable behaviors behind it.
What one operation does to the database: lock mode, cost class, touched relations.
Operation -> [Effect]. Total: unmapped classes get the conservative default.
The (operation class, engine, version range) -> {lock, cost} mapping. This is DATA, not conditionals; layer 4's lock-verification suite is its empirical anchor. Anything absent returns :unmapped and Effects applies the conservative default (AEL + rewrite + tripwire finding).
One judged fact: mechanism + scale + provenance, with source location.
A parsed migration file: attributes + ordered operations. operations
holds the deploy direction (up/change); down_operations holds the
rollback direction (down bodies, plus the down leg of two-arg
execute), judged only when mix cerbero.check --down asks.
Static AST analysis of migration source. Never compiles or executes user code: Ecto's DSL macros call the private, repo-bound Ecto.Migration.Runner, so interception would mean replicating private API. Cost: dynamically-generated operations are invisible — they are emitted as %Unknown{}, never silence.
Typed operations mirroring Ecto migration DSL semantics, with source lines.
alter table(...) with its add/modify/remove column ops.
create constraint(...), including CHECK body and validate option.
create index(...), including unique/concurrently options.
create table(...) with its column definitions.
drop index(...), including the concurrently option.
drop table(...).
execute "..." with the classifier's reading of each statement.
rename table(...), to: ... (table or column rename).
A dynamically-built operation the static parser cannot read — never silence.
Keyword-heuristic classification of raw SQL in execute/1,2. This is
deliberately NOT a SQL parser: anchored patterns over normalized text,
with :unknown as the honest fallback (surfaced as unclassified_sql).
DML is detected (target table), never analyzed.
One classified raw SQL statement: its class plus the identifiers the patterns captured.
severity(lock, cost, scale, traffic, config, multiplier).
The snapshot artifact: decode, verify, canonically re-encode.
Opt-in precision: :order_of_magnitude export mode: buckets counts and
bytes to their power-of-ten floor so a committed snapshot does not reveal
exact business metrics (a subscriptions row count is a revenue proxy).
Canonical JSON: object keys sorted lexicographically, 2-space indent, LF endings, trailing newline. The checksum is computed over these bytes, and stable ordering is what makes PR diffs reviewable, so this encoder must stay byte-stable across Elixir/OTP versions.
Builds a raw snapshot map from a live connection (or a DBA-returned file). Session is read-only with a short statement_timeout — defense in depth, not the privacy mechanism (that is the Queries allowlist).
EVERY SQL statement the exporter can run, on one reviewable screen. No dynamic SQL beyond schema-name parameters and the quoted migrations-table identifier. The only non-catalog read is the versions column of the migrations table. This module is the privacy allowlist's first layer — review it like one.
Optional Ed25519 tamper-proofing for snapshots.
Staleness degrades confidence, never fails unrelated PRs. Past the headroom window, severity thresholds shrink (a table at 600k rows three weeks ago is judged as if at the 1M tier). Past the degrade age, every row count becomes unknown → unbounded, so a stale snapshot cannot silently certify anything — but PRs with no pending migrations still pass. The age findings themselves are emitted by the snapshot_health rule, which consumes this struct.
One table's decoded catalog metadata: stats, columns, indexes, constraints.
Mix Tasks
Parse pending Ecto migrations (static AST — your code never runs), fold
their effects into the catalog model recorded by mix cerbero.snapshot,
and judge each operation by lock mode × cost class × your production scale
and traffic. Meant to run in CI, where no database is reachable: the exit
code is the verdict.
Write a .cerbero.exs config file with every setting spelled out at its
built-in default and commented, so the full surface is visible in one
place. Deleting any line falls back to the same default — the file is a
starting point to edit, not a required manifest. See Cerbero.Config for
the meaning of each key.
Export a snapshot of your database's catalog metadata — schema shapes,
row/byte estimates, traffic counters, index/constraint validity — to a
canonical, checksummed JSON artifact you commit. mix cerbero.check
judges pending migrations against it. No row data, expression text, or
literals are ever exported (see the privacy boundary in the README).