Data quality ยท self-healing pipelines

Agent Proposes, DQX Disposes

Every serious data platform grows a dead-letter table, and it always becomes a graveyard. Here is how we turned one into a self-healing queue — without letting a language model anywhere near the warehouse.

Every data platform that takes quality seriously ends up with a dead-letter table. You run pre-commit checks, and the rows that fail don’t go into Silver — they go into quarantine. It is the responsible thing to do. It is also, in practice, where data goes to die.

Nobody wants to open the quarantine table. The rows that land there are, by definition, the annoying ones: a code with a stray alphabetic prefix, a timestamp in the wrong format, a duplicate that got emitted twice, a foreign key that doesn’t quite line up because one side trims whitespace and the other doesn’t. Fixing them is fiddly, one-off work, and the backlog only grows.

We built a system that closes the loop. An LLM agent triages quarantined rows and proposes fixes. Those fixes are re-validated deterministically against the exact checks that quarantined the row in the first place. Only the rows that pass are merged back into Silver. Everything else escalates to a human review queue instead of looping forever.

The interesting part isn’t the LLM. It’s the constraints around it.

01Two design principles

Everything in the system falls out of two rules we decided not to break.

“The agent proposes. DQX disposes.”

Principle one

The agent never writes to Silver or Gold. Not once. Every agent-curated row is re-run through the same data-quality engine — we use DQX, the Databricks Labs quality framework — that quarantined it. If the “fix” doesn’t actually make the row pass the checks, it doesn’t get in. This keeps a hallucinated correction from silently corrupting the warehouse, and it makes the whole thing auditable: the gate is code, not vibes.

“Documented fixes beat guessed fixes.”

Principle two

If a rule owner has already written down exactly how a violation should be remediated — “primary-key collision on customer_id: keep the row with the latest updated_at, drop the rest” — then that fix is applied deterministically and the LLM never sees the row. The agent is the fallback for rules nobody has written a playbook for yet. It is not the default path.

That second principle matters more than it sounds. The cheapest, most reliable, most auditable model call is the one you never make.

The re-ingestion loop: quarantined rows are fixed by a playbook or an agent, re-validated against the same DQX checks, and only passing rows merge back into Silver. Silver quarantine table playbook fix agent fix re-validate same DQX checks human review queue row fails fail · low confidence pass
Figure 1. The loop. A quarantined row is fixed by a documented playbook strategy or, failing that, by the agent — then re-validated against the identical DQX checks before anything touches Silver.

02The shape of the pipeline

The whole thing runs as a Databricks Workflow. Because a For Each task can only wrap a single task, not a sub-graph, the per-table pipeline is a child job that the parent fans out to — one child run per quarantine table.

The parent job discovers quarantine tables and fans out with a For Each task; each child run walks triage, playbook, agent, gate, validate, reingest, audit. PARENT  —  dqx_agentic_reingest 01 discover For Each quarantine FQN run_job → child × N CHILD  —  dqx_agentic_reingest_process  (one run per table) 02 triage 03a playbook 03 agent curate conf. gate 04 apply + validate 05 reingest → Silver 06 audit + metrics conf ≥ threshold below → skip to audit pass revalidated_fail → retry (cap 3)
Figure 2. The Workflow DAG. Parent discovers and fans out; each child run walks the same seven-stage path. 06 runs on at least one success of reingest or a false gate, so every table produces an audit row either way.
A recreation of the parent job run graph in Databricks Workflows: the discover task, then a For Each task fanned out to one child run per quarantine table, all succeeded. Workflows / dqx_agentic_reingest / Run 4821 Succeeded · 6m 04s 01 · discover 12s For Each · quarantine_fqn 12 / 12 iterations succeeded CHILD RUNS — dqx_agentic_reingest_process process · silver_orders41s process · silver_customers38s process · silver_shipments52s process · silver_transactions47s process · silver_accounts29s process · silver_inventory35s + 6 more tables — all succeeded
Figure 3. A recreation of the parent job’s run graph in Databricks Workflows — discover, then a For Each fan-out to one child run per quarantine table. Table names and durations are illustrative.

Each stage writes only to a control table — a review queue with one row per quarantined row per pass. Nothing touches Silver until the final merge, and that merge only ever sees rows that have already passed re-validation.

03Triage

Pull the unprocessed rows from one quarantine table, dedupe to the latest quarantined version per row, and flatten the failure metadata. DQX records failures as an array of structs — {name, message, …} — and triage flattens those names into a plain array<string> of violated rule names, then seeds the review queue with status pending.

It also captures the business key at this moment. Reconstructing a natural key later, after the row has been patched, is guesswork; capturing it at quarantine time is not.

04Playbook remediation — the deterministic half

This is where documented fixes get applied. Each dataset has a YAML file — not in the codebase, but in a Unity Catalog Volume, so adding or changing a rule needs no deploy:

remediation_playbooks / silver_orders.yaml
playbooks:

  # structurally broken — not a recoverable formatting slip
  - rule_name: code_not_matching_regex
    priority: 10
    where: "code rlike '^[A-Za-z]'"
    strategy: reject_rows
    params: { reason: "alphabetic prefix on a numeric code" }

  # documented tie-breaker: keep the most recently updated row
  - rule_name: struct_customer_id_order_ts_is_not_unique
    priority: 20
    strategy: dedupe_keep_latest
    params:
      partition_by: [customer_id, order_ts]
      order_by: updated_at
      order_direction: desc

Entries run in priority order; the first one that matches a row wins. Three things we learned to bake in:

  • where predicates let one rule route by value. status = 'Duplicate' goes to dedupe; status = 'Invalid' has no entry, so it falls through to the agent. Same rule, different handling, driven by the data.
  • reject_rows is a terminal state, not a failure. Some rows genuinely can’t be recovered. Dropping them is a decision the rule owner made on purpose, and it is recorded as such — not retried, not escalated, not seen by the agent.
  • Dedup is configured only here. The agent never deduplicates. Choosing which of two near-identical rows survives is a business call with a documented tie-breaker, not something to infer.

The strategy functionsdedupe_keep_latest, standardize_value, fill_default, reject_rows — are code. A new kind of fix needs a deploy. A new instance of an existing kind is just a file upload. Behavior in code, policy in config: that split is what keeps the playbook maintainable by the people who own the rules rather than the people who own the pipeline.

05Agent curation — the fallback

Only rows with no playbook match reach the agent. And here is the decision that makes this affordable:

One model call per distinct violation signature. Not per row.

The cost model

If 40,000 rows all failed the same check on the same column in the same way, that is one call. The model sees the rule name, the check function, the message, a sample of failing values, and up to five passing examples per flagged column. It returns a single deterministic transform per column, chosen from a fixed vocabulary:

trim lower_trim upper_trim regexp_replace {pattern, replacement} regexp_extract {pattern, group} to_date {from_format} to_timestamp {from_format} left_pad {length, pad} right_pad {length, pad} substring {pos, len} set_default {value} set_null

Spark then applies that transform to every row in the signature. The model does not see 40,000 rows, does not write 40,000 fixes, and does not get to invent an expression. It picks lower_trim for a casing problem or regexp_replace to strip a known prefix, and that is the entire surface area. Token cost is independent of quarantine volume.

Transforms can be chained — strip a prefix, then zero-pad — which covers most of the real formatting damage: typos, column shifts, date formats, casing, padding, regex-strippable junk. And we guard against no-ops: if the model proposes left_pad on a string already at the target length, that is not a fix, and it gets downgraded to an escalation.

06The cross-table probe — no model at all

Referential-integrity checks — a row in table A must match a row in table B — are common, and the LLM is bad at them. So before the agent is involved, a deterministic probe runs: on a sample, normalize the compared column (trim, lower_trim, upper_trim) on both sides of the join and check whether the mismatch clears. If a normalization resolves, say, 95% of the sample, propose that transform. If it doesn’t, escalate.

A surprising fraction of “broken foreign keys” are just one side of a join carrying trailing whitespace.

07The confidence gate

The agent attaches a confidence score to every fix decision. Between curation and application, a gate checks the minimum confidence across the batch against a threshold. Above it: proceed to auto-apply. Below it: skip straight to audit, and those rows wait for a human.

One low-confidence signature holds back the whole batch for that table. We would rather under-automate than merge a shaky fix.

08Apply and re-validate

For rows that clear the gate: apply the patch, strip every bookkeeping column, and re-run the row through approved_checks_for(silver_table) — the same rules table that quarantined it. Not a copy of the checks. Not a checks file that might have drifted. The same source. “Re-run the exact checks that quarantined it” is only true if both sides read from one place.

Rows that pass are staged. Rows that fail get retry_count += 1 and go back into the queue. After three failed attempts a row auto-escalates regardless of confidence. Nothing loops forever.

09Reingest

A dynamic MERGE INTO Silver, keyed on the business key that triage captured, columns intersected with the target schema. Every reingested row is tagged with lineage: agent_curated = true, the confidence score, the run id, the timestamp. Downstream consumers who don’t trust agent-curated data can filter it out. That is their call to make, and we give them the column to make it with.

row_idrule_violationssourceactionconf.status
a1f3…struct_…_is_not_uniqueplaybookdrop1.00resolved_duplicate
b8c0…code_not_matching_regexplaybookreject1.00rejected
c2d9…order_date_bad_formatagentpatch_fields0.94reingested
d5e1…region_not_in_listagentescalate0.41escalated
e7a4…fk_customer_missingagentpending
Figure 4. The review queue — one row per quarantined row per pass. Terminal states in green/red, human work in amber.
Illustrative outcome split for one run: about 51,412 rows resolved by the deterministic playbook, 168 fixed by the agent and reingested, 24 escalated to a human. 51,412 rows resolved by the deterministic playbook — 99.6% of 51,604 quarantined dedupe 38,940 · structural reject 12,472 192 rows had no playbook entry and went to the agent: 168 agent-fixed → reingested 24 escalated to a human Illustrative figures. The deterministic-first split is the point — the agent sees a fraction of a percent of the volume.
Figure 5. Illustrative outcome split for a single run. The playbook clears the bulk; the agent handles a couple hundred; a couple dozen reach a human.

10Dry runs

Because “an LLM will now edit rows that flow into our warehouse” is a sentence that makes people nervous, there are two ways to see what would happen without committing anything.

  • A read-only estimate that computes the deterministic half for real — match, apply strategy, re-validate — and reports the agent half as a count only. The model is never called. One row per table into a report table, nothing else.
  • A full dry run that runs the whole pipeline, calls the agent, writes the queue and the staging table — but holds the final MERGE and instead reports the exact insert/update split against Silver.

Run the first before you turn the system on. Run the second in staging when you want to see precisely what will land.

11What this bought us

The quarantine table stopped being a graveyard. On the first real run, the large majority of quarantined rows resolved deterministically — dedup and structural rejects that rule owners had already documented. A few hundred went to the agent. A couple dozen genuinely needed a human, and those are the ones a person should be looking at.

The lesson we keep relearning: the LLM is the least important component. The value is in the scaffolding — the deterministic-first routing, the fixed transform vocabulary, the re-validation gate, the retry cap, the confidence threshold, the dry runs. The agent is a fallback that turns “a human triages every weird row” into “a human triages the genuinely ambiguous ones.”

Agent proposes. DQX disposes. Silver stays clean.