Skip to content

Steps: referenced implementations

Some cleansing genuinely is code. Entity resolution across source systems, fuzzy matching, ML scoring — none of it is expressible as a declarative mapping, and pretending otherwise produces worse specs, not better ones.

bloomery's answer keeps the principle intact: specs describe, specs reference implementations, specs never contain implementations. A step is platform code, in git, reviewed as code, described by a versioned manifest, and referenced from a spec by use: ref@version.

The ladder

Use the lowest tier that works.

Tier Kind Scope What bloomery can do Reach for it when
0 DSL transform expression typecheck fully the whitelist covers it
1 sql_macro expression parse and typecheck one gnarly expression
2 sql_model table parse, infer schema multi-step SQL, windows, recursive CTEs
3 python_model table nothing — trust and verify fuzzy matching, ML, genuinely not SQL

Most "we need Python" requirements are Tier 1 or 2 on inspection — a surprising number of Python steps are one expression wearing a dataframe.

Tier 3's costs are real and worth naming before you choose it: data leaves the engine, the step becomes memory-bound, and column-level lineage is gone (lineage: coarse). Tier 1 costs nothing at run time at all — the macro body is spliced into the model's query, so the model stays one query and lineage sees straight through it.

What a manifest declares

The manifest lives in the platform repo, beside the step body — never in bloomery, never in a spec:

ref: resolve_customers
version: 3
kind: python_model
entrypoint: platform_steps.resolve_customers:resolve
determinism: pure
runtime_lock: sha256:a91f…
inputs:
  raw: {grain: customer_source_row, requires: [source_system, source_id, email]}
outputs:
  customer:
    grain: customer
    key: [canonical_id]          # what the runtime assertion enforces
    produces:
      canonical_id: {type: string, required: true}
      confidence:   {type: decimal(4,3)}
parameters:
  threshold: {type: decimal(4,3), default: 0.85, min: 0, max: 1}
lineage: coarse

grain is prose for humans; key is the machine-readable half, and the difference matters — a grain sentence cannot be checked, a key can.

An input's requires names the columns the step reads, and it is a lower bound the compile checks. When the wiring binds the input to an entity the project declares, or to another step's output, every required column has to be one of that relation's columns (the entity's fields, or the output's produces), or the compile refuses with a StepError naming the input and what is missing. Extra columns pass. Two things are not checked: a relation the project does not declare, such as a silver table kept outside bloomery, and a reserved column name like _source_row_id, whose presence depends on the relation's quality configuration.

What a spec wires

Wiring, and nothing else:

steps_version: 1
steps:
  - use: resolve_customers@3
    inputs:  {raw: silver.customer_raw}
    outputs: {customer: silver.customer, customer_xref: silver.customer_xref}
    parameters: {threshold: 0.9}

Quality rules on an output

A step output takes S-0033 expression rules, and applies_to says which output each judges — an entity's quality: has one relation to mean, a step has several:

steps_version: 1
steps:
  - use: resolve_customers@3
    outputs: {customer: silver.customer}
    quality:
      - {rule: expression, name: confident, expr: "confidence >= 0.8", on_fail: fail}
    applies_to: {confident: customer}

Which dispositions lower depends on the tier, because they need different things from the model:

on_fail: sql_model python_model
fail blocking audit over the relation blocking audit over the relation
flag _quality_flags / _quality_ok on the relation compile error
quarantine compile error compile error

fail needs no SELECT — it reads the finished relation and returns the rows that violate the rule, which is what a blocking audit is.

flag adds the two generated columns every silver entity carries, so it needs a SELECT to project into. A Tier 2 body is one, and gets them. A Tier 3 wrapper writes its rows in Python, so there is nothing to project into and the refusal names the tier.

quarantine is refused on both, and not as a "not yet". Routing a row means writing it to <output>__reject, which is keyed on the ingestion metadata a bronze extract carries and a step wrote none of; and quarantine.retention is required wherever the disposition appears, with no quarantine: block in a steps: wiring to declare it. Route the rows in a downstream mapped entity instead.

An output with no flag rule keeps exactly the columns its manifest declares — the two generated ones appear when a rule needs them, never by default.

How a parameter reaches a SQL body

A Tier 2 body refers to its parameters as sqlglot placeholders, the same :name spelling Tier 1 uses:

SELECT canonical_id, confidence
FROM silver.candidates
WHERE confidence >= :threshold

Each placeholder is replaced by a literal node, not by text, so a value is data wherever it lands — a parameter cannot carry SQL into the body. The declared type picks the spelling: int and decimal become number literals, bool a boolean, and string, date and timestamp string literals the engine compares in the column's own type.

The body and the step's parameters must name the same set, and each mismatch is a compile error rather than something an engine discovers:

Situation Why it is refused
:x that no parameter declares it would reach the engine as an unknown variable
:x declared with no default and not wired same — declaring a parameter is not giving it a value
a parameter the body never uses its value is part of the step's identity, so changing it restates the outputs and recomputes identical rows

A variant parameter cannot be substituted into a body: DuckDB, Postgres and Trino do not write a semi-structured literal the same way. Pass a scalar and cast in the body instead.

Linking an output to canonical fields

A mart can read a step output as soon as it exists. A metric needs one more thing: it resolves against canonical fields, and something has to say which canonical field a produced column is.

steps_version: 1
steps:
  - use: resolve_customers@3
    outputs: {customer: silver.customer}
    canonical:
      customer:
        confidence: match_confidence

The link lives on the wiring rather than in the manifest, because canonical names are your spec's vocabulary — a manifest naming them could not be reused by a project that spells them differently, which is the fork "parameterize, never fork" exists to prevent.

It is never inferred from a matching column name. A column called confidence is not assumed to be the canonical confidence: guessing a link nobody declared is exactly what the compiler refuses elsewhere, and it does not become acceptable because the guess is cheap.

With the link declared, nothing else is special. The column becomes available, metrics over it are reachable, and a reconcile: check may name the step's relation on either side.

There is no field here that can hold a body, and none that can name a file to load. That absence is the security property: a spec can no more load code than a metric name can. The registry is assembled by the caller and passed in — compile_project(project, …, steps=registry) — so bloomery never reads a step file from disk, and there is no dynamic loading path to abuse.

Using a macro (Tier 1)

A macro is referenced from the mapping that uses it, never wired in the steps: document — it writes no relation, so it has no output to bind there, and one wiring per step ref would make a macro usable in exactly one mapping.

Its manifest declares a signature: what it accepts, and (through produces) what it returns.

ref: extract_domain
version: 1
kind: sql_macro
determinism: pure
runtime_lock: sha256:beef…
accepts:
  email: string
outputs:
  value: {grain: row, key: [v], produces: {v: {type: string}}}

The body lives in the registry the caller assembles, as one expression whose :name placeholders are exactly what the manifest declares:

SPLIT_PART(:email, '@', 2)

Two ways to call it

As a field, binding each accepted column to a source path — the shape to reach for when a macro takes more than one column:

fields:
  email_domain:
    step: extract_domain@1
    from: {email: "$.email"}

As a chain link, so whitelist transforms and the macro compose on one field. The running value fills the macro's single accepted column, so this shape needs a macro that accepts exactly one:

fields:
  email_domain:
    from: "$.email"
    transform: [lower, {step: extract_domain@1}, trim]

What is checked

Because the signature is declared rather than guessed from the body, a chain is typechecked around the macro: the transforms before it against what it accepts, the transforms after it from what it produces. A macro is therefore no weaker than a Tier 0 transform, which declares the same two things.

Situation Why it is refused
the body refers to something the manifest does not declare the signature would be decoration, and a call site could not know what to supply
a call site binds something the macro does not accept the path would be read for nothing — a typo is otherwise silent
a call site omits something it accepts the placeholder would reach the engine unfilled
a two-column macro used as a chain link a chain carries one running value, so an argument would be dropped
a macro declaring anything but determinism: pure it is re-evaluated on every backfill by construction

A genuinely polymorphic macro has to pick a type. That is the same constraint a Tier 0 transform carries through its input domain, and the same trade: a declared type is what makes the check possible at all.

Trust at compile, verify at run time

bloomery cannot infer a Python function's output schema, so it trusts produces at compile time: downstream models typecheck against it, the DAG stays complete, and plan() computes backfills across the step.

Then the generated wrapper checks reality against the declaration on every run — outputs present, none undeclared, exact column set, assignable types, required columns null-free, declared grain unique over its key. That assertion is non-optional and non-configurable by construction. There is no flag to turn it off, because a claim that is checked is a commitment and a claim that is not is a comment.

Each declared output becomes its own model, and every wrapper asserts all declared outputs — so a step that lies about one of them is caught wherever the run happens to start.

The assertion is bloomery's own, imported by the generated wrapper:

from bloomery.steps import assert_step_contract as _blm_assert

That path is public API, and unusually so: the importer is code bloomery wrote into your repository, so renaming the module would break every wrapper generated before the rename, at run time, with nothing at compile time to warn you. It is declared in bloomery.steps.__all__ and covered by the stability policy like any other export. The older bloomery.steps.contract path keeps working.

Determinism is not negotiable

Tier Meaning What bloomery does
pure same inputs, same outputs backfills freely
seeded deterministic given a seed the seed is required in the wiring, and recorded
nondeterministic clock, network, unseeded RNG compile error

A nondeterministic step makes a backfill disagree with the original run, which destroys the ability to restate — the one capability the whole architecture is organized around. Refusing it is the load-bearing constraint, not caution.

The declaration is trusted, and caught behaviorally if it lies: a step secretly reading datetime.now() passes the contract check and fails the backfill-equivalence gate. That is why that gate stays merge-blocking.

Why runtime_lock exists

A step's behaviour depends on its libraries. rapidfuzz changing a scorer between minor versions silently changes entity-resolution output, and nothing in any spec would show it.

runtime_lock — a hash of the pinned dependency set, computed at registry build time — is part of the step's identity in the IR. So a dependency bump changes the project fingerprint, plan() classifies it RESTATING, and the step's outputs land in the backfill scope. Correct behaviour, and invisible without the lock.

The same is true of a parameter change, a new seed, or rewired inputs: they are all fields on the step's IR node, so they all restate. There is no special-casing for steps anywhere in plan() — that is the whole design.

Parameterize, never fork

Steps are platform code. A spec configures parameters; it never supplies a body. When something is needed that the library cannot do, the step is generalized into a parameterized form — never resolve_customers_acme.

A requirement that genuinely cannot generalize is a useful signal rather than a problem: it is bespoke consulting, not product, and knowing that explicitly beats discovering it later in a directory of near-identical step files.

Before adding a spec kind, ask this

The registry exists so the answer can be "make it a step". The test:

Can bloomery typecheck it, guardrail it, and diff it meaningfully? If not, it is a step.

A concept bloomery cannot verify is a concept where a spec kind gives weaker guarantees than a step, because a step at least carries a declared, runtime-enforced output contract. Identity resolution is the worked case — blocking keys, similarity functions and thresholds are none of them typecheckable, and runtime_lock makes upgrading the matching library a restating change that backfills the outputs, which a declarative similarity function could never do. See Resolve identities across systems.

This is what keeps the library smaller than the problem it solves. A spec kind is a permanent surface: it has to parse, typecheck, guardrail, diff, emit on three targets and survive every future version. A step is a manifest.

Migrating existing code

Wrap, don't refactor. Write a manifest for the script you already have, register it as @1, declare lineage: coarse, and claim pure only after the backfill-equivalence gate has verified it rather than because it seems true.

Then get it into the DAG. Once plan() can see the step, backfills stop being something a human remembers to run.

Then push down the ladder. Most SQL scripts collapse to Tier 2; a surprising number of Python ones turn out to be Tier 1 expressions. Extract declared rules — quality rules, transforms — as you go.

A version bump requires new fixtures. A @4 reusing @3's expected outputs is a review failure: if the outputs did not change, it should not have been a version bump.