Skip to content

Errors

Every failure bloomery raises derives from BloomeryError — one except BloomeryError catches everything — and every class lives in bloomery.errors, importable without pulling in any pipeline stage.

The hierarchy

BloomeryError
├── SpecParseError
├── UnknownTransformError
├── TypeCheckError
├── TransformRegistrationError
├── ResolutionError
│   ├── CircularDerivation
│   └── MissingReference
├── GuardrailError
│   ├── UnitMismatch
│   ├── TaxBasisMismatch
│   ├── CurrencyMismatch
│   ├── GrainMismatch
│   ├── AdditivityViolation
│   ├── AssertLoweringError
│   ├── GrainViolation
│   ├── FanoutRisk
│   ├── NonAdditiveWithoutComponents
│   ├── MartMissingTimeDimension
│   ├── QuarantineRetentionMissing
│   ├── DedupeTieBreakMissing
│   ├── DedupeDispositionConflict
│   ├── IngestionMetadataMissing
│   └── RedactionConflict
├── PlanError
│   ├── ContractViolation
│   └── RenameTargetMissing
├── EmitError
│   └── UnsupportedByTarget
└── PlannerError
    ├── UnknownMember
    ├── UnreachableAtGrain
    ├── AmbiguousDimension
    ├── InvalidRequest
    ├── FilterTypeMismatch
    └── UnsupportedFilter
        ├── UnsupportedSetRelation
        ├── UnsupportedHierarchy
        ├── UnsupportedTextOperator
        ├── FilterTooComplex
        ├── UnsupportedNegation
        ├── InvalidLiteral
        ├── UnsupportedSortNulls
        ├── UnsupportedPagination
        ├── UnsupportedFieldCompare   (adapter-owned)
        └── UnsupportedQuantifier     (adapter-owned)

Per class

Class Stage Raised when
BloomeryError Base class; carries message, source_path, and collected
SpecParseError parse YAML failures, duplicate keys, unknown keys, shape/grammar violations — batched per document
UnknownTransformError typecheck A transform chain names a transform absent from the registry; message names the closest match
TypeCheckError typecheck Unparsable type strings; a chain's terminal type not assignable to the declared type; precision-cap overflows
TransformRegistrationError registration register_transform given an invalid spec or a colliding name
ResolutionError resolve Cross-spec reference and recipe failures over the dependency DAG — batched per stage
CircularDerivation resolve Any cycle in the dependency DAG; message names the full cycle path
MissingReference resolve A spec references a nonexistent entity, field, canonical field, template, or relationship end
GuardrailError guardrails The batched aggregate of guardrail violations, sorted by (source_path, type name)
UnitMismatch guardrails +/- operands with differing declared unit (currency + count)
TaxBasisMismatch guardrails net and gross — or an unknown basis beside a monetary operand — meeting in additive arithmetic
CurrencyMismatch guardrails Two distinct declared currencies with no explicit convert step in the chain
GrainMismatch guardrails An expression combining columns of different grains without explicit aggregation
AdditivityViolation guardrails An aggregation contradicting the metric's declared additivity
AssertLoweringError guardrails An assert: clause ill-typed against the field's logical type
GrainViolation guardrails A mart measure whose grain does not strictly equal the mart grain
FanoutRisk guardrails A mart via: flatten step over a one_to_many relationship
NonAdditiveWithoutComponents guardrails A non-additive metric with no ratio/additive decomposition to recompute from
MartMissingTimeDimension guardrails A measure-carrying mart that declares no date role
QuarantineRetentionMissing guardrails An entity with a quarantine disposition and no quarantine: block — reject rows hold raw payloads, so retention is required and never defaulted
DedupeTieBreakMissing guardrails dedupe: {keep: latest_by} without tie_break — rows sharing a timestamp would make the winner arbitrary
DedupeDispositionConflict guardrails A coercible rule weaker than fail on a field named by dedupe.field/tie_break, where an uncastable value leaves the dedupe order undefined
IngestionMetadataMissing guardrails An entity using quarantine:/dedupe: whose mapping neither maps nor acknowledges _load_id, _ingested_at, _source_row_id
RedactionConflict guardrails A quarantine.redact path intersecting a path the mapping reads — replay re-runs the mapping against raw, which the redaction has already destroyed
PlanError plan A spec diff that cannot produce a safe migration plan (including IR-version mismatch)
ContractViolation plan Dropping or narrowing a field still referenced by a reachable metric — expand/contract enforced
RenameTargetMissing plan A renamed_from annotation whose old name is absent from the old IR
EmitError emit The IR cannot be lowered to a target artifact; also unknown target/dialect names and emitter-registration collisions
UnsupportedByTarget emit An IR construct the selected target or dialect cannot express — fail loud, never approximate
PlannerError planner A malformed or unanswerable request; also the fallback for untranslated backend failures
UnknownMember planner A request names a metric or dimension that does not exist; message carries a did-you-mean
UnreachableAtGrain planner No single mart can answer the request at the requested grain — refused, never joined at plan time
AmbiguousDimension planner An unqualified reference to a dimension with multiple roles; message names the roles
InvalidRequest planner Bad filter/order/limit shapes, duplicates, malformed filter documents
FilterTypeMismatch planner A filter value whose type contradicts the dimension's logical type — refused before any SQL renders
UnsupportedFilter planner Base of the closed query-vocabulary refusal family (RFC 0015): every leaf carries a stable .reason code and, where the refusal happens after normalization, .normalized — the post-normalization form
UnsupportedSetRelation planner $superset/$subset/$disjoint/$overlaps — marts are flattened and scalar; no array columns exist to relate
UnsupportedHierarchy planner $descendant_of/$ancestor_of — model hierarchy as flattened level columns on the mart
UnsupportedTextOperator planner $regex (dialect-divergent, unbounded cost) and $empty (ambiguous across types) — use like/ilike, eq "", or is_null true
FilterTooComplex planner CNF expansion exceeded the clause cap (default 64), refused during distribution
UnsupportedNegation planner A negated leaf with no complement operator (e.g. $not $like)
InvalidLiteral planner A non-finite numeric operand (NaN/Infinity, float or string form — fails open if permitted) or an invalid like pattern (unpaired trailing \, NUL)
UnsupportedSortNulls planner A nulls placement other than the canonical default (first for asc, last for desc)
UnsupportedPagination planner A non-zero offset or cursor pagination — paging aggregates belongs to the serving layer
UnsupportedFieldCompare adapter $fields field-to-field compare — declared here so app adapters can raise it; never raised by bloomery, and not part of KNOWN_UNSUPPORTED
UnsupportedQuantifier adapter $any/$all/$none element quantifiers — declared here so app adapters can raise it; never raised by bloomery, and not part of KNOWN_UNSUPPORTED

Data-quality refusals without their own class

Five data-quality guardrails have named leaves (above). The rest raise a bare GuardrailError — the design authority names five, and minting further classes would put names in bloomery.errors no RFC has decided on. Each is still a distinct, addressed message inside the same batched aggregate:

Refusal Raised when
Pattern portability A pattern rule one of the shipped dialect ports (DuckDB, Postgres, Trino) declares no regex surface for, or whose text SQLGlot will not carry into that dialect's SQL unchanged. The subset the pattern must speak is enforced earlier, at parse, as a SpecParseError
dedupe naming an unknown column dedupe.field or a tie_break entry the entity does not declare — it lowers straight into ORDER BY <column> DESC NULLS LAST, so a typo would fail in the engine's binder on a model that compiled clean
via naming no relationship A referential rule whose via matches nothing in the entity model's relationships: — a referential rule probes a declared relationship; there is nothing to join on otherwise
via declared from another entity A referential rule naming a relationship whose from side is a sibling — the join reads that relationship's columns off this entity's extract, which never projects them
Self-referencing referential A referential rule whose relationship's to side is the declaring entity — the rule lowers to a LEFT JOIN inside that entity's own model, and a model cannot join the table it is being built from
unknown_member on a non-string fk referential: {on_missing: unknown_member} where the foreign key is not string-typed; the reserved member is the string '__unknown__', and typed sentinels like -1 could collide with a legal key
unknown_member on a composite key The same disposition on a relationship joining through more than one column — the rewrite is one CASE over one column, so a composite fk would get a half-sentinel key matching no reserved row
Entity rule name a generated rule owns An entity-level quality: rule named the same as a rule generated from the mapping (a field rule, an implicit coercible, a referential named after its relationship) — that name is the key of a quality-mart time series and an entry in failed_rules, so one of the two would have to be silently renamed
Reconcile grammar and resolution A side outside the closed shape, an undeclared entity, an unknown column, sides keyed on different columns, or a duplicate check name
Reserved metric name A project metric colliding with one the quality mart owns (quality_rows_evaluated, quality_rows_failed, quality_rows_quarantined, quality_rows_deduped, quality_quarantine_rate) — one flat namespace, and two definitions of one name is a silent winner, not a merge

Data-quality refusals also happen at emit time rather than compile time, and those are UnsupportedByTarget. Two are about the dialect, both on the absent NULL-on-failure cast: compiling an entity with coercible rules for Postgres (RFC 0016 D30), and — because the ingestion-metadata audit asserts _ingested_at casts to timestamp, which is a TRY_CAST of its own — compiling a dedupe-only entity for Postgres too, even though it carries no quality rules at all (D31). One is about the target: a quarantine: block or a reconcile: check compiled for dbt, which lowers neither in this wave. All of them name the target or dialect that does support the construct.

The closed refusal list

The UnsupportedFilter family is a closed, reviewed list (RFC 0015), not drift: every construct the query vocabulary cannot express was refused deliberately, with a named error and a rationale. The stable .reason codes raisable by bloomery's three parse functions (parse_filter_json, parse_sort_json, parse_page_json) are exported as bloomery.planner.KNOWN_UNSUPPORTED and drift-guarded by test — adapters key their refusal handling (HTTP problem responses, UI messages) on .reason, and assert their own refusal sets against the export. Anything not on the list must translate; growing the list is a reviewed decision, never an accident.

A refusal is not the same thing as malformed input. The parse functions raise UnsupportedFilter only for constructs the vocabulary reviewed and declined (some during tree construction, some after the rewrite — see the API reference) — a document that is simply ill-formed (a non-mapping payload, a field map of the wrong shape, an unknown $op, an operand of the wrong type, a nulls value that is neither "first" nor "last", a non-int limit) raises InvalidRequest and never carries a .reason from the closed list. Handle the two separately: a .reason is a product decision to surface, an InvalidRequest is a caller bug to fix.

source_path

Every error carries an optional source_path — a dotted/bracketed address into the authored document, prefixed with the document's name (the key you passed to load_project, or a deterministic label like mapping[shop__orders->order] where parsed models no longer know their file):

mappings/shopify.yaml: fields.unit_price.from

The parse stage always sets it; later stages set it best-effort.

Batching

The parse, resolution, and guardrail stages batch: they collect every individual failure in the stage and raise one aggregate whose message lists every path, so a spec is fixed in one round-trip rather than one error at a time. On an aggregate, exc.collected holds the individual errors as a tuple for machine consumption; BloomeryError.from_collected(errors) is the constructor that builds one. A stage with a single failure raises that failure directly, uncollected.

Planner errors are deliberately not batched — a request fails on its first problem, because request-time callers want one actionable refusal, not a report.