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
├── InvariantViolated
├── SpecParseError
├── UnknownTransformError
├── TypeCheckError
├── TransformRegistrationError
├── StepError
│ ├── StepDeterminismError
│ └── StepContractViolation
├── ResolutionError
│ ├── CircularDerivation
│ └── MissingReference
├── GuardrailError
│ ├── UnitMismatch
│ ├── TaxBasisMismatch
│ ├── CurrencyMismatch
│ ├── GrainMismatch
│ ├── AdditivityViolation
│ ├── AssertLoweringError
│ ├── GrainViolation
│ ├── FanoutRisk
│ ├── HistoricalFanout
│ ├── NonAdditiveWithoutComponents
│ ├── FalseAdditivityClaim
│ ├── InvalidMetricShape
│ ├── MetricFilterInvalid
│ ├── MartMissingTimeDimension
│ ├── UnprovableRollup
│ ├── ReservedEntityName
│ ├── DanglingExposure
│ ├── DanglingExport
│ ├── UnexportedImport
│ ├── UnknownUpstream
│ ├── ImportCollision
│ ├── ImportCycle
│ ├── NamelessImporter
│ ├── UndeclaredZone
│ ├── UndeclaredRatioRows
│ ├── RatioOperandsDisagree
│ ├── QuarantineRetentionMissing
│ ├── DedupeTieBreakMissing
│ ├── DedupeDispositionConflict
│ ├── IngestionMetadataMissing
│ ├── RedactionConflict
│ ├── SecretPublished
│ └── AudienceWidened
├── PlanError
│ ├── ContractViolation
│ └── RenameTargetMissing
├── EmitError
│ └── UnsupportedByTarget
├── ArtifactImportError
└── 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 |
InvariantViolated |
any | A guarantee an earlier stage was supposed to have established did not hold — never an authored spec's fault, and never spec feedback. Every total lookup that raises it is total because a guardrail already refused the case, so seeing one is a bug report |
StepError |
steps | Base of the referenced-implementation family (S-0034) |
StepDeterminismError |
steps (compile) | A step declaring determinism: nondeterministic, or a seeded step wired without a seed — a nondeterministic step makes a backfill disagree with the run it replaces |
StepContractViolation |
steps (run time) | Raised by the wrapper bloomery generates into your warehouse: the step's actual output contradicts its manifest — a missing or undeclared output, a differing column set, an unassignable type, or a NULL in a required column. The one error here a reader meets outside a compile |
SpecParseError |
parse | YAML failures, duplicate keys, unknown keys, shape/grammar violations — including an expr: that is not parseable SQL — 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 meeting in one expression; no token waives it — convert answers it by producing a column declared in the target currency |
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 |
HistoricalFanout |
guardrails | A mart that flattens an scd: type2 entity with neither an as_of: anchor nor reading: current, or is based on one without reading: current; one that declares as_of: or reading: current on a non-historical entity; or a flatten step declaring both. A mart reading current versions that is not materialization: full is refused as a plain GuardrailError |
NonAdditiveWithoutComponents |
guardrails | A non-additive metric with no ratio/additive decomposition to recompute from |
FalseAdditivityClaim |
guardrails | A metric declared additive that is not: an aggregation outside the sum/min/max/count allowlist, for which no sound rollup is known — each is told the repair for its own case, or a measure whose entity key carries a date and whose mart offers that column as a dimension — one row per thing per period, with the axis exposed to sum across. Also a distinct_count over anything but count_distinct, or with no expr to be distinct over — the same claim false in the other direction |
InvalidMetricShape |
guardrails | A metric whose declaration contradicts itself — derived: beside cumulative:, a derived metric declared additive, a cumulative one with no measure to accumulate, or a derived expression referencing an alias its inputs: do not declare |
MetricFilterInvalid |
guardrails | A metric filter: naming a dimension the carrying mart does not flatten, a date-role dimension, or a value that does not fit the column's declared type |
MartMissingTimeDimension |
guardrails | A measure-carrying mart that declares no date role |
UnprovableRollup |
guardrails | A declared rollup carrying a measure that may not be re-aggregated over the dimensions it drops — a non-additive or distinct-count measure, one the parent mart does not store, or one whose filter:/cumulative: the rollup's aggregate would drop |
ReservedEntityName |
guardrails | An entity named canonical, exposure, mart, metric, source or step — every lineage node id but an entity field's is <prefix>.<rest>, so such an entity mints ids in another kind's namespace. An entity named after an upstream this project imports is refused on the same terms: an imported node is spelled <kind>.<alias>.<name>, so the alias is a segment of the same namespace |
InsufficientEvidence |
guardrails | A mart declaring requires_evidence: locked reads a measure resting on a fact nobody wrote down — sound under the proof floor, but not authored here, which is the different question the consumer asked |
DanglingExposure |
guardrails | An exposure whose depends_on names a metric or mart the project does not declare — the dependency would match no change, so the impact report would name nobody and still read clean |
DanglingExport |
guardrails | An export list naming an entity, mart or metric the project does not declare — nothing is built for an export, so the name publishes nothing while reading as published, and the refusal lands one project and one compile away from the author who can fix it |
UnexportedImport |
guardrails | A project imports a name the upstream does not export — the refusal that makes an export list mean something, since without it "exported" would be a label with no consequence; the message names the upstream's list |
UnknownUpstream |
guardrails | A project declares an import from an upstream this compile was not given — how the upstream reaches a compile is the caller's, so what was passed is the whole world, and the message names it |
ImportCollision |
guardrails | A name a project declares is also one it imports — two things of one kind answering to one name, where any precedence rule would be invisible from the other project's file |
ImportCycle |
guardrails | An upstream that was itself compiled against this project — A importing B while B imports A, or the same loop through a third project. Cycles are refused, never resolved: neither project can be compiled first, and no order is invented for them. The message names the chain link by link; the fix is deciding which project owns the shared concept |
NamelessImporter |
guardrails | A project that both imports and exports, with no name: on its export list — a project in the middle of a chain is identified by its export name, since an alias is the downstream's own spelling and a fingerprint moves whenever an upstream does. A project that exports without a name may still be imported; it may not import |
UndeclaredZone |
guardrails | A timestamp parsed from a wall clock nothing declares a zone for, whose absolute position is then read — a date role, a comparison against a literal instant, or an as-of anchor. Declare it with zone_in: UTC where the source's wall clocks really are UTC, or {to_utc: <zone>} naming the clock they run on. The compiler checks that the assertion exists, never that it is true |
UndeclaredRatioRows |
guardrails | A ratio whose denominator can be zero on a row, with nothing saying whether that row belongs in it — the row contributes to the numerator and nothing to the denominator, so its amount is charged to the units other rows carried. Restrict both operands, declare the field positive at a disposition that removes the row, or declare includes_zero_denominator: true for the total-over-units reading |
RatioOperandsDisagree |
guardrails | A ratio whose numerator and denominator are restricted to different row sets — a quotient of two quantities about different things, which is wrong whether or not a zero is involved |
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 |
SecretPublished |
guardrails | A mart or rollup carrying a column classified secret. A published relation is the one thing secret says the column is not part of, so the two authored statements cannot both hold. Unconditional — it does not depend on redaction, quarantine, or anything being granted |
AudienceWidened |
guardrails | A pii/secret column reaching a relation that admits a role its source entity does not — a set difference rather than a superset test, so disjoint sets are refused as well — the customer table flattened into a wide mart and the mart granted to everyone. Both sides must declare grants: an undeclared audience is unknown rather than wider, and is an advisory instead |
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 |
ArtifactImportError |
imports | An external semantic artifact does not state a relationship exactly enough to read one out of it — a foreign entity element nothing declares unique, two models claiming one target, a missing or non-column expr, a semantic model naming no entity this project declares. Also a cardinality disagreement with a relationship the project already declares. Every one of them is a refusal to guess: there is no weaker relationship to emit instead |
PlannerError |
planner | A malformed or unanswerable request; also the fallback for untranslated backend failures |
UnknownMember |
planner | A request names a metric or dimension that exists nowhere in the project; message carries a did-you-mean |
UnreachableAtGrain |
planner | The request cannot be served at the requested grain. Measures spanning grains are aggregated per mart and joined afterwards where every requested dimension is provably the same dimension on each; this is what is raised when they are not, when a filter or the row policy names a dimension some branch does not carry, when a metric's own components cannot each be answered by one branch, or when no mart serves a measure at all. Also a dimension another mart carries and this one does not, where .refusal_reason says whether one line of spec would fix it |
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 (S-0032): 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 |
| Freshness with no ingestion contract | A freshness: block on a mapping whose target entity declares neither quarantine: nor dedupe: — only those make _ingested_at mandatory, so the emitted loaded_at_field would name a column that may not exist and the check would error at run time on a project that compiled clean |
| Freshness thresholds that disagree | Two mappings giving one bronze relation different freshness: thresholds, named on both sides — sources.yml holds one entry per relation, so one would be silently dropped. Equal thresholds collapse, and a mapping that declares none is making no statement about the relation rather than disagreeing |
seeds: |
A key that exists in order to be refused, permanently. A seed is a table of data in your repository and bloomery reads no files while compiling (S-0020), so the rows would have to live in a spec — which would make the spec a data file. The message says "refused, not missing", because an author told only that a key is unknown expects it in a later release |
| 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 an absent NULL-on-failure
cast: an entity with coercible rules (S-0033/D-30), and — because the
ingestion-metadata audit asserts _ingested_at casts to timestamp, which is a
TRY_CAST of its own — a dedupe-only entity too, even though it carries no quality
rules at all (D31). A third is about an absent Unicode normalization (D86). None of
the three can fire on a shipped dialect: DuckDB and Trino have TRY_CAST, Postgres
gets a guard around its own input parser (D84), and all three normalize. They are what a
fourth dialect would meet if it arrived without the capability, and they are provoked in
the test suite against exactly such a dialect.
A fourth is about a target's mechanism rather than its SQL: a grants: block is
refused for Cube, because Cube reads relations it does not own and so has nothing to
apply a grant with. Emitting it anyway would put a restriction in a file that restricts
nothing, and dropping it silently would let a project believe a restriction it declared
is in force on every target it compiles for.
None is about a target's artifact families any more. A quarantine: block and a
reconcile: check were refused for dbt until it grew the reject table, the replay macro
and the comparison model; what dbt still refuses is a Tier 3 python_model step, which
is about the adapters dbt's Python models run on rather than about anything bloomery
lowers. All of them name the target or dialect that does support the construct.
Retrieval refusals¶
A retrieval document declares a surface over relations the project already builds, and seven
guardrails make that declaration mean something. All seven raise RetrievalViolation, a
GuardrailError subclass that lives in bloomery.guardrails.retrieval rather than in
bloomery.errors — retrieval ships as its own spec kind, nothing outside that module
raises the class, and promoting it into the exported taxonomy is a decision no design
authority has taken. Catching GuardrailError catches all of it, and every one arrives
inside the same batched aggregate, addressed retrieval: profiles.<name>.
Every disagreement is reported, not the first. A profile is authored as one block and is usually got wrong in more than one way at once — a space renamed, its dimensions bumped and a column dropped are one edit, and reporting them one at a time would be three round trips.
| Refusal | Raised when |
|---|---|
| Dimensions disagree | The vector field's declared dimensions differ from its space's — a distance over vectors of different lengths cannot be computed at all |
| Scalars disagree | The field's scalar differs from the space's; a float32 corpus scored against a float16 query is a silently different space, and it returns plausible neighbours while being one |
| The field is not a vector | The profile's vector.field is declared some other type. A field that is not a declared vector carries no dimensions and no scalar, so nothing about the space can be checked against it — "any array will do" is how a corpus comes to hold two models' embeddings |
| Producers disagree | The profile's vector.producer differs from the space's document_encoder. Dimensions agreeing is not spaces agreeing, and this comparison is the only check on an encoder identity: it is two strings, never a lookup against a provider |
| Analysers disagree | The profile's lexical.analyser differs from the analyser its corpus relation declares. A corpus tokenised one way and queried another matches on terms neither side produced — the lexical form of a producer mismatch, and like it, two strings compared and never resolved against a store. Both sides have to be declared: one alone, or neither, is not a disagreement |
| Grain is not the relation's key | The profile's grain differs from the corpus relation's key — for a mart, its base entity's key. One vector per retrievable item, or the corpus is not a corpus: a coarser grain has either duplicated a vector or collapsed several, and both make top-k meaningless |
| A named field is absent | A lexical, filterable or return field — or the vector field itself — the corpus relation does not carry. The message ends on the columns it does carry, capped at eight, because the mistake is usually a column renamed in one document and not the other |
An eighth refusal is about the relation rather than the profile: a profile naming an entity or a mart this project does not build declares a surface nothing can serve, and every other check on it has nothing to read. It reports the relation and stops there rather than cascading six more leaves off one missing name — which is also what a mart that failed its own check looks like from here, one leaf in the same batch beside the mart's own.
What is not here: anything about a vector store, an index, or whether an encoder model
exists. A declared vector is also not a column a warehouse port emits DDL for, so
compiling a vector-carrying relation for a SQL target is an UnsupportedByTarget ("no
physical type for vector(float32, 1536)") rather than a retrieval refusal — the relation
is written by the platform's step, and what bloomery contributes is the contract for
querying it. See Retrieval.
The closed refusal list¶
The UnsupportedFilter family is a closed, reviewed list (S-0032), 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.
Fix suggestions¶
Five refusals carry a structured next action beside the prose, because a human reads a message and a program reads a structure. Each field exposes a value bloomery already computed on its way to writing the message:
| Class | Field | Carries |
|---|---|---|
UnknownMember |
did_you_mean: str \| None |
The closest known metric or dimension name |
UnreachableAtGrain |
covering_marts: tuple[MartCoverage, ...] |
One entry per required measure: the mart that does serve it, and the grain it serves it at |
UnreachableAtGrain |
refusal_reason: str |
Why a dimension is out of reach: "not_flattened", a RefusalReason value, or "" for the measure cases above |
GrainViolation |
offending_measures: tuple[MeasureRef, ...] |
The measure at odds with the mart grain, and its own grain |
UnknownStep |
available_versions: tuple[int, ...] |
The versions of that ref the registry holds, ascending |
UnsupportedFilter |
nearest_supported: str \| None |
The operator that would have worked ($regex → "like") |
MartCoverage(mart, metric, grain) and MeasureRef(measure, grain) are frozen public
dataclasses, exported from the package root. They are typed values rather than encoded
strings on purpose: a tuple[str, ...] could name the marts but not say which metric
each covers at which grain, and that pairing is the whole content of the conflict.
from bloomery.errors import UnreachableAtGrain
try:
planner.plan(ir, request, dialect="duckdb")
except UnreachableAtGrain as refusal:
for entry in refusal.covering_marts:
print(f"{entry.metric} lives on {entry.mart} at grain {entry.grain}")
A dimension another mart carries¶
Asking for a dimension the serving mart does not flatten used to refuse as UnknownMember
with a guess at the nearest column name. That is false whenever the dimension exists on
another mart: it sends you to declare something already declared. Those requests now refuse
as UnreachableAtGrain, and refusal_reason answers the only question that changes what
you do next.
"not_flattened" means the rollup to that dimension's grain is provable and the mart
simply does not carry the column — one line of spec fixes it, and the message names the
line:
dimension 'region' is not on mart 'order_items', which serves this request; mart 'orders'
carries it at grain 'order'.
Values at 'order_item' roll up to 'order' safely, so the column can be flattened onto
this mart at build time: add `flatten: {via: item_of_order}` to mart 'order_items'.
Any other value is a RefusalReason — the rollup itself does not hold, and that member
names which repair applies:
from bloomery.errors import UnreachableAtGrain
from bloomery.semantic import RefusalReason
try:
planner.plan(ir, request, dialect="duckdb")
except UnreachableAtGrain as refusal:
if refusal.refusal_reason == "not_flattened":
... # the message names the mart and the flatten to add
elif refusal.refusal_reason:
reason = RefusalReason(refusal.refusal_reason)
The same diagnosis reaches a filter or a row policy naming such a column: all three resolve the dimension through one function, and a filter on a column another mart carries has the same cause and the same repair as a group-by does.
bloomery never joins at plan time, in either case. The join belongs to the mart, where it is proven once when the mart is built rather than re-decided on every request — so the repair is always a spec edit, never a flag on the query.
Absence has one representation, and "the attribute is missing" is not it. Every field
is always present: a collection field is () and a scalar is None when there is
nothing to suggest, so no caller needs getattr. An empty suggestion is a fact rather
than a search that was skipped, and the two emptinesses mean different repairs —
covering_marts == () means no mart lists the metric at all (define one), while a
populated tuple means the metrics are split across grains (request them separately).
Nothing is fabricated. $empty is refused because eq "" and is_null true are
different questions, so nearest_supported is None there and the message names both;
a set relation has no scalar counterpart at all. And nothing is discoverable only
through a suggestion — the primary contract stays the message and, for
UnsupportedFilter, the .reason code.
nearest_supported is the Op value rather than the member, because bloomery.errors
imports nothing. Op is a StrEnum, so refusal.nearest_supported == Op.LIKE holds and
Op(refusal.nearest_supported) round-trips.
Advisories are not errors¶
Some findings are worth saying and not worth refusing over. Those are advisories:
evaluate() returns them on SpecEvidence.advisories, sorted and deduplicated, and no
exception is raised. They are a separate channel from the hierarchy above — nothing here
is ever raised, and nothing above is ever reported as an advisory.
The bar is deliberately high, and it is the same sentence in three parts: the spec is legal, the compiled artifacts are correct, and there is still something you would want to know. Anything where the numbers could be wrong stays a refusal. bloomery does not soften "refuse rather than answer wrongly", and an advisory standing where a refusal belongs is a defect in bloomery, not a convenience.
Branch on code, never on the message: the vocabulary below is a closed, reviewed list
and each addition is a change someone signed off, while message text is prose that
improves between releases.
| Code | Raised when |
|---|---|
undeclared_audience |
A mart or rollup publishes a pii/secret column and no grants: block says who may read it — on the relation, on the source entity, or on either. bloomery has no opinion about the audience, so whatever the warehouse already grants stands. Declare grants on both and the guardrail refuses a widening instead |
inexact_division |
A catalog recipe's expr: divides. The divide transform is marked so PostgreSQL and Trino keep it in exact decimal arithmetic, but a recipe's expression is parsed SQL carrying no marker — so the division happens in binary floating point and is narrowed back to the declared decimal, on every engine rather than only on DuckDB. Where the division must be exact, use a divide/multiply transform chain instead |
Advisories carry a source_path under the same rules as an error's, below.
Two channels advisories are deliberately not:
- Log records. bloomery emits nothing at
WARNING— that severity belongs here, and a finding that was only logged is a finding a caller who configured no handler never received. See stability for the logger hierarchy. DeprecationWarning. A spelling on its way out is announced through Python'swarningsmodule underBloomeryDeprecationWarning, so a suite run with-W errorsees it. That is a statement about the library's surface; an advisory is a statement about your spec.
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.