Skip to main content
The rest of the CI Contract checks a single build against a fixed contract an identity that has to close, a bound that has to hold. This group checks something different: that a change didn’t quietly make things worse than they already were, against either a historical snapshot or the model’s own prior output.
A style fix or a refactor must never move a model’s actual output that’s a property transform/.sqlfluff and the singular tests above can’t check on their own, since neither one reads a model’s full row-for-row content. The oracle does: it computes an order-independent content hash of every materialized model each row hashed as its struct cast to text, then aggregated with string_agg(... ORDER BY rh) so row order can never affect the result and diffs it against a committed baseline (transform/tests/model_hashes.baseline.json).The seven fct_* marts are the mandatory byte-stable subset: drift in any of them is a hard failure (--check exits 1). Drift in any other model is reported as a warning, not a failure the marts are the layer’s external contract, so they’re held to a stricter bar than an intermediate model nobody outside the DAG reads directly. One column family is excluded from the hash on principle rather than by exception list: wall-clock build metadata like fit_timestamp would make every single build “drift” against the last one for a reason that has nothing to do with model logic.Real byte-stability needs one more thing besides the hash itself: DuckDB’s internal thread count pinned to 1 in the dbt profile (settings: threads: 1), because non-associative floating-point aggregation can reorder under parallelism dbt’s own threads: setting only controls how many models build concurrently, not the intra-query parallelism inside any one of them. duckdb is also pinned in requirements.txt so the float-content hashes match across machines and CI runners, not just within one.Treat the baseline the way you’d treat an approval test: when a model’s logic changes intentionally, regenerate and commit it with make lint-oracle-snapshot. The gate exists to catch the unintentional case a lint autofix, a refactor that was supposed to be a no-op not to freeze the models in place.
sqlfluff lint models/ is a genuinely enforcing hard-fail gate, run in CI and via make transform-check. It uses the dbt templater, wired through .sqlfluff to the project’s own checked-in profile (profiles_dir = profiles, target = ci) rather than falling back to a developer’s local ~/.dbt/profiles.yml the same models render identically whether the gate runs on a laptop or in CI.Exactly one model is excluded, via .sqlfluffignore: fct_ghost_race_finish.sql exceeds sqlfluff’s parser depth limit and is hand-maintained against every applicable rule instead of machine-checked. It’s a lint limitation, not a style exemption the model still has to follow the same conventions, just without the automated check.