seeds/public_test_exceptions.csv is the one place this package records known,
accepted data-quality test failures, so a pipeline stops alerting on a situation we
have already triaged.
Alerting on these tests is task-level: the Airflow task running them fails if any test in it fails, and there is no per-test routing. So the only way to stop alerting on a known failure is to stop the test returning those rows — which is what this table does, without disabling the test.
temporary — an accepted failure we expect to go away. expires_on is
required. When it passes, the exception stops applying and the test starts failing
again; that is the review mechanism, which is why there is no separate nag for stale
rows and no "forever" value.
structural — a permanent carve-out that is correct by design, with no expiry.
These are the exclusions that used to be hard-coded into the test SQL, where nobody
could see them without opening the file. Two rows arrived this way:
| test | carve-out | why |
|---|---|---|
sac_asset_code_matches_metadata_symbol |
XLM SAC CAS3J7GY…OWMA |
publishes native as its SEP-41 symbol while staging rewrites asset_code to XLM |
token_transfers_no_negative_circulating_supply |
CB4PO24U…34E4 |
non-compliant custom token that burns more than it mints; outside our control |
A structural row still needs a reason, and it is still validated — the point is
that it is visible and reviewable in one place, not that it is unchecked.
One carve-out is a rule rather than a row: token_transfers_no_negative_circulating_supply
skips every contract in seeds/contract_token_balance_sources.csv. Their pre-handover
supply exists only in contract storage, so a burn of it reads as negative supply;
contract_token_handover_matches_storage checks those balances against storage instead.
- Add a row to
seeds/public_test_exceptions.csv. Every column is described inseeds/public_test_exceptions.yml. - Scope it as tightly as the failure really is:
- name an
entity_key(contract, batch, table…) so only those rows are forgiven - add
day_from/day_towhen the failure is about specific days - leave
entity_keyempty only to suppress the test wholesale, and pair that with a shortexpires_on
- name an
- Run
dbt seed --select public_test_exceptionswherever the tests run, before the next test run. Until the seed has been loaded at least once, the wired tests fail withTable … public_test_exceptions was not found— run the seed, not a rollback.
Every singular test in tests/ is wired. Its own scope lives in its own config()
block, not in a separate registry file — entity_column must be one of the names
declared there; the day column is what a day_from/day_to range is compared
against.
target_key |
entity_column |
day scope |
|---|---|---|
bucketlist_db_size_check |
none | date(closed_at) of the ledger |
eho_by_ops |
batch_id |
date(batch_run_date) |
int_token_transfer_enrichment_amount_matches_current_metadata |
contract_id |
the day compared |
ledger_sequence_increment |
ledger_id, batch_id |
date(closed_at) |
no_missing_days_in_snapshot |
table_name |
first day of the gap |
no_missing_ledgers |
table_name |
date(closed_at) of the gap |
num_txns_and_ops |
batch_id |
date(closed_at) |
sac_asset_code_matches_metadata_symbol |
contract_id |
none — current-state metadata |
token_transfers_no_negative_circulating_supply |
contract_id |
none — aggregates all history |
The entity columns are alternatives, not a composite key: a row names one of them.
Row-scoped exceptions only work for tests whose SQL we author, and whose predicate
can be appended to a final where. There is no central registry macro to edit — a
test registers itself. To wire one:
- declare its scope in its own
config()block:{{ config( ... , meta={"exception_scope": {"entity_columns": ["my_column"], "allows_day_scope": true}} ) }} - call
exclude_test_exceptions()in the test's finalwhere:(from outside this package, qualify:{{ exclude_test_exceptions( ref('public_test_exceptions') , entity_columns={'my_column': 'some_alias.my_column'} ) }}stellar_dbt_public.exclude_test_exceptions(...))
target_key is never passed — it is always the test's own name (model.name), and
its declared scope is always that same test's own config.meta.exception_scope.
exclude_test_exceptions cross-checks the entity_columns/day_column passed at
the call site against that declaration, so a call site and its own meta cannot
silently drift apart.
validate_test_exceptions (the check behind tests/test_exceptions_valid.sql)
builds its registry by scanning every test node in the current project for a
declared meta.exception_scope — there is nothing to keep in sync by hand, and
nothing to forget to register. It checks rows against that dynamically discovered
registry, so a mistyped entity_column fails loudly instead of quietly forgiving
nothing. It is deliberately not itself wired to exclude_test_exceptions — excepting
the validator would let a malformed row silence the check that catches it.
That registry can only see enabled tests. dbt drops disabled nodes from graph
entirely — there is no graph['disabled'] in the Jinja context, checked through dbt
1.12 — so on a target where a wired test is switched off (five of them are
enabled=(target.name == "prod" ...)), its perfectly valid exception row is
indistinguishable from a typo'd target_key. That is why the validator takes
check_registration, and why the test passes target.name == "prod":
| on prod | off prod | |
|---|---|---|
target_kind, exception_kind, reason, owner, expires_on, entity_column without entity_key, inverted day range |
checked | checked |
target_key is a wired test |
checked | skipped |
entity_column is one the target exposes |
checked | skipped for tests disabled here |
day_from/day_to allowed by the target |
checked | skipped for tests disabled here |
So a typo'd target_key is still caught, on the one target where every wired test is
enabled and the seed is authoritative. Before this, the package's own test failed on
every non-prod target on a valid seed — staging alerted every 30 minutes from
2026-09-04 until it was fixed.
Both macros live in macros/test_exceptions.sql and are shared with any project
that installs this package. That's safe because config/model/graph context is
bound to whichever node is currently compiling, not to whichever package
physically defines the macro being executed — so config.meta and graph always
reflect the caller's own test, never this package's, even when called qualified
from a downstream project. Two dbt/Jinja gotchas fell out of building this:
config.get()doesn't see a node's ownconfig()call untilexecuteis true. dbt renders each node's Jinja at least twice — an early pass used to build the dependency graph, and a laterexecute=truepass that produces the real SQL. Readingconfig.metabeforeexecuteis true returns stale/empty data, so the meta read and validation inexclude_test_exceptionsare both guarded with{% if execute %}. The emitted predicate never depends on meta (target_keyismodel.name;entity_columns/day_columnare arguments), so it renders identically either way and sqlfluff's non-executing lint pass is unaffected..get()/.values()aren't safe method calls onconfig/graphdict-likes. They duck-type as mappings (is mappingpasses) but dbt's sandboxed Jinja environment silently coerces them to their string form before the call, so.get(...)fails with a confusing'str object' has no attribute 'get'. Subscript (d["key"]) andinare safe and used throughout instead.
The generic tests in tests/generic/ (recency, incremental_not_null,
incremental_unique, …) are not wired. They run once per model, so a row would have
to name the model as well as the test, which exception_scope does not model yet.
That is the natural next step, and it is what would let a consumer of this package
except a single stale table's recency failure without deleting the test.
stellar-dbt installs this package and has its own test_exceptions seed for its
own tests, with the same schema. The two are deliberately separate tables, even
though they share the mechanism:
- each repo's validator scans its own project's test nodes, so a shared table would make each one flag the other's rows as unregistered
- an exception lives in the same repo as the test it excepts, so they are reviewed together
What IS shared is the code: stellar-dbt calls this package's
exclude_test_exceptions() and validate_test_exceptions() directly
({{ stellar_dbt_public.exclude_test_exceptions(...) }}) rather than keeping a
forked copy. Its own tests declare their own meta.exception_scope exactly the same
way this package's do; only one implementation of the predicate-building and
validation logic exists.
Because stellar-dbt disables this package's seeds wholesale
(seeds: stellar_dbt_public: +enabled: false), it must re-enable this one seed for
the wired tests to resolve.