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 will cost nothing at run time at all — the macro body is spliced into
the model's SELECT, so the model stays one query and lineage sees straight
through it. It is not wired yet: there is no spec surface by which a
mapping references a macro step, so a wired sql_macro is a compile error
today rather than a splice that quietly does not happen.
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.
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 step outputs are described by RFC 0017 §5.2 but are not lowered yet — declaring them is a compile error rather than a rule that is accepted and never evaluated.
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.
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.
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.
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.