Skip to content
Back to skills

Incremental Load Scan

ASecurity

Batch and incremental loads: lookback, merge key, deletes, dedup, schema drift, overwrite scope, backfill, CDC.

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 20, 2026
ai-agentspythongojavaphpbashsqlnodespringdockergit

Works with

  • cursor
  • cli
  • api

Security analysis

A100/100

Pro scans all 2 files and shows the line behind each finding

Scanned October 20, 2026

npx -y skills add adnanmokhtar/refract --skill incremental-load-scan --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Incremental Load Scan?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Incremental Load Scan
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/adnanmokhtar-incremental-load-scan/badge)](https://www.skillsdirectory.com/skills/adnanmokhtar-incremental-load-scan)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: incremental-load-scan
description: "Batch and incremental loads: lookback, merge key, deletes, dedup, schema drift, overwrite scope, backfill, CDC."
kind: skill
pack: data-engineering
allowed-tools: [Read, Grep, Glob, Bash]
---

# Skill: incremental-load-scan

## Premise

An incremental load is a promise that every source row lands in the target **exactly once and stays current**: a row committed with the same timestamp as the last run's maximum, a row that arrives two days late, a row updated after it was loaded, a row deleted at the source, a run retried after a crash, a backfill over a range already loaded. Each is a different mechanism, and none fails a test that loads ten rows once. **The defaults are the defect** (dbt docs, accessed 2026-10-19): `on_schema_change` is `ignore`, so new upstream columns never arrive; `append` never deduplicates and ignores `unique_key`; a NULL in any `unique_key` column never matches, so the merge inserts a duplicate; snapshot `hard_deletes` is `ignore`, so a deleted entity stays current forever. Spark's `partitionOverwriteMode` is `STATIC` (Spark 4.2 configuration), so `mode("overwrite")` deletes every partition, not only the ones the run wrote.

This skill owns research ids IN-1…IN-10 (`research-data-engineering.md` § 1.1), shipped as `DE-IN-<n>`: **whether a batch or incremental load writes each source row exactly once and keeps late, updated and deleted rows.**

**Every finding cites the load `<file:line>` + the effective config it runs under and where each value came from (model `config()` → properties YAML → `dbt_project.yml` `+key` → default) + the source row that is lost or doubled (a tie, a late row, a NULL key, a delete, a retry) + the oracle or the test that would settle it + the fix in the project's own tool.** A grep hit is a candidate. "The loads look idempotent" is never printed; the output is the class-coverage block (§ Output). Verification follows [`refute-before-report.md`](../../../../snippets/refute-before-report.md) with [`precedents/data-engineering.md`](../../../../snippets/precedents/data-engineering.md); the oracles are [`proof-oracles.md`](../../../../snippets/proof-oracles.md) § Data engineering, V540–V546, V549 and V550.

## The rule

A candidate is a finding when all four hold:

1. **Reachable.** The load runs on a schedule or a trigger that production uses: a dbt job in CI/cron/dbt Cloud, an Airflow/Dagster/Prefect task, a Spark job, a scheduled application writer, a CDC connector. A one-off backfill script that already ran fails here unless it is the documented re-run path.
2. **The contract is read.** The grain (what one target row is), the source's mutation pattern (append-only, updated in place, hard-deleted), and the latest a row can arrive — from the model's docs, the source system, or the loader. Without the grain the row is `[report-flagged]` with the grain named as the gap.
3. **No existing control, across files.** A merge on the grain key, a lookback window, a `delete … where <partition>` before the insert, a `qualify` with a total order, a `not_null` test on the key, a soft-delete flag the staging model filters, an explicit `on_schema_change`, `partitionOverwriteMode=dynamic` or Delta `replaceWhere`, a CDC sink that merges by key and source position.
4. **The oracle fired, or the fact is two-ended.** The replay/late-row/delete oracle ran on a scratch target and the target is wrong; or the static fact cites both ends — the effective config value and its source, plus the source system's behaviour that makes the default wrong (a source with `updated_at`, a source that hard-deletes).

## Boundaries

- **Message consumers and CDC *consumers* in application code** (dedup, ack order, poison messages, outbox, gap-blind event cursors) → distributed-systems `messaging-scan` §1–§3 and `dual-write-scan` §5/§6, when that pack is installed; without it the row is `[report-flagged]` here with the route named. This skill owns a CDC stream **landing into a warehouse or lake table** (§10).
- **A report's filter, definition or zone** → business `report-scan`; **an application scheduler's overlap, misfire or zone** → backend `job-lifecycle-scan` §4–§6; **a data orchestrator's run semantics** (wall-clock windows, catchup, trigger rules, retries) → `orchestration-scan` (this pack). OR-5 (retries) and §2 here meet on the same write: the non-idempotent write is §2's; the retry that replays it is `orchestration-scan` §5's.
- **Whether a data test can fail** → `data-test-scan`; **an in-app rollup or materialized view** (counter increments, recompute windows) → `derived-data-scan` (this pack); **the cost of a merge with no target bound** → `warehouse-scan-audit` §2.
- **The backfill *plan*** stays `/backfill-plan`; §8 here reports the load shape that makes a backfill double-count.

## Effective config — defaults the verdict reads

| Setting | Default when unset | Source (accessed 2026-10-19) | The class it feeds |
|---|---|---|---|
| dbt `materialized` | `view` | dbt docs | — |
| dbt `incremental_strategy` | adapter default — printed `adapter-default`, read from the installed adapter's docs, never guessed | dbt Incremental strategy | §2 |
| dbt `unique_key` | none: every returned row is inserted | dbt Incremental models | §2 |
| dbt `on_schema_change` | `ignore`: added columns silently not added, removed columns fail the run | dbt Incremental models | §6 |
| dbt snapshot `hard_deletes` (≥ 1.9) | `ignore`; `invalidate_hard_deletes` is the ≤ 1.8 flag | dbt Snapshots | §4 |
| dbt microbatch | `event_time`, `begin`, `batch_size` required; `lookback` 1; Postgres merges (needs `unique_key`), BigQuery/Spark insert-overwrite (need `partition_by`); all times UTC | dbt Microbatch | §9 |
| constraints | enforced in full only on Postgres; Snowflake, BigQuery, Redshift, Databricks enforce `not_null` (Databricks also `check`) and keep PK/unique/FK as metadata | dbt constraints | §2 (a declared PK is no dedup proof) |
| Spark `spark.sql.sources.partitionOverwriteMode` | `STATIC`: overwrite deletes every matching partition; Hive serde tables are always dynamic | Spark 4.2.0 configuration | §7 |
| Spark `mergeSchema` | `false` | Spark 4.2.0 configuration | §6 |
| Debezium (Postgres) | a delete is an event plus a tombstone; a table with no PK under `REPLICA IDENTITY DEFAULT` emits no update/delete; unchanged TOAST columns arrive as `unavailable.value.placeholder`; 3.2 replaced `drop.tombstones` / `delete.handling.mode` | Debezium 3.7 Postgres connector docs; 3.2.0.Final notes (2025-07-09) | §10 |

`scripts/model-graph.py` (this skill's folder; stdlib Python 3.8+, `--self-test`) is the pack's shared dbt reader: it parses the project **without dbt** — models, `ref`/`source`, effective configs with their source, `is_incremental()` branches, snapshots, tests, sources, exposures, unit tests — and writes `models.tsv`, `edges.tsv`, `tests.tsv`, `sources.tsv`, `snapshots.tsv`, `incremental.tsv`, `candidates.tsv` and `graph.json` under `--out` (refused inside the scanned root). Exit 0 built · 2 blocked · 4 no dbt project. `data-test-scan`, `lineage-trace`, `contract-diff`, `warehouse-scan-audit` and `grain-probe` read the same tables; quote the printed source of every effective value in a finding.

## Adapt to the codebase — the lanes

Read the tool **and its version** from the files (`dbt_project.yml` `require-dbt-version`, a `dbt-core` / `dbt-<adapter>` pin, `pyspark` / `delta-spark` pins, the Debezium image tag, `package.json`), never from memory, and print the lanes you ran in the output header.

| Lane | Detect by | Per-class read | Fix primitive |
|---|---|---|---|
| **dbt** (Core / Fusion, any adapter) | `dbt_project.yml` | `model-graph.py` candidates `IN-*` (configs, `is_incremental()` branches, snapshots, tests on key columns) — no dbt run needed | `unique_key` + merge / delete+insert; `>=` + lookback; `qualify row_number() over (… order by ts, id)`; `on_schema_change: append_new_columns` or `fail`; `hard_deletes: invalidate` / `new_record`; microbatch keys |
| **SQLMesh** | `MODEL (` in `*.sql`, `config.yaml` | `kind INCREMENTAL_BY_TIME_RANGE` (needs `time_column`), `INCREMENTAL_BY_UNIQUE_KEY` (`unique_key`), `lookback` | the kind's own keys |
| **Spark / Databricks** | `pyspark`, `SparkSession`, `.write.mode(`, `MERGE INTO`, notebooks (`.py`, `.ipynb`) | overwrite mode, `dropDuplicates`, window order, `mergeSchema` | `partitionOverwriteMode=dynamic`, Delta `replaceWhere`, `MERGE` on the key with a `whenMatchedDelete` branch |
| **Plain SQL / Python ETL** | `INSERT INTO … SELECT`, `COPY`, `MERGE`, `to_sql(`, `executemany(`, `read_sql(` in a scheduled file | watermark read, advance and compare; dedup on write | `ON CONFLICT … DO UPDATE` / `MERGE`; advance the watermark after the commit, in the same transaction |
| **In-app scheduled writers** (Nest `@Cron`/`@Interval`/`@Processor`, BullMQ, Celery beat, Sidekiq, Laravel `schedule`, Spring `@Scheduled`) | a schedule marker ∩ a write in one file (grep 0e) | the same as plain SQL; the rollup/MV classes are `derived-data-scan`'s | the same |
| **CDC landing** (Debezium, Kafka Connect sinks, Fivetran/Airbyte soft-delete columns) | connector JSON/YAML/properties, `_fivetran_deleted`, `_ab_cdc_deleted_at` | tombstones, delete handling, sink insert mode, PK-less tables | upsert/merge sink keyed by PK + source LSN; staging filters the delete flag |

Version gates: dbt ≤ 1.8 vs ≥ 1.9 (snapshot flags, microbatch), dbt ≥ 1.11 telemetry variable prefix `DBT_ENGINE_`; Spark 3 vs 4 (ANSI on by default in 4); Debezium < 3.2 vs ≥ 3.2 (delete options renamed).

## Pre-flight

Run as **one** script from the repo root (bash 3.2-safe). Grep **N** feeds §N; G and 0 build the surface. Every `rg` names its path; include globs first, exclusions last, every exclusion `**/`-rooted; a path glob is never combined with an extension glob (ripgrep ORs two positive `-g` globs). Every grep ends in `ck`: rg exit 2 is a **blocked** class, never zero hits. Nothing is written outside `$OUT`.

```bash
ROOTS=(.)   # monorepo: name the pipeline and app roots, e.g. ROOTS=(analytics dags apps/api/src)
OUT="${OUT:-$(mktemp -d "${TMPDIR:-/tmp}/de-inc.XXXXXX")}"; mkdir -p "$OUT/none"
h() { printf '## %s\n' "$1"; }; ck() { [ "$2" -lt 2 ] || echo "$1 BLOCKED (rg exit $2) — the class is blocked, never 0 hits"; }
X=(-g '!**/node_modules/**' -g '!**/vendor/**' -g '!**/{dist,build,out,target,coverage,.next,.venv,venv,dbt_packages,logs}/**'
   -g '!**/{test,tests,__tests__,spec,specs,e2e,fixtures,__fixtures__,mocks,__mocks__,testdata}/**'
   -g '!**/*.{spec,test,e2e-spec,stories,d}.*' -g '!**/test_*.py' -g '!**/*_test.{py,go}' -g '!**/conftest.py'
   -g '!**/{locales,locale,i18n,translations}/**' -g '!**/{docs,ai,.claude}/**' -g '!**/*.{md,mdx,lock,min.js}')
XM=(-g '!**/{migrations,migration,db/migrate}/**')
CODE=(-g '*.{py,ipynb,scala,sql,ts,mts,js,mjs,rb,php,java,kt,go,cs}')
NC=(rg -v --pcre2 '^[^:]+:\d+:\s*(?://|/\*|\*(?:\s|/|$)|#|--)')
# G  the dbt graph without dbt (incremental-load-scan/scripts/model-graph.py): exit 0 built · 4 no dbt project (dbt lane N/A) · 2 blocked
MG=.claude/skills/incremental-load-scan/scripts/model-graph.py
if command -v python3 >/dev/null 2>&1 && [ -f "$MG" ]; then python3 -I -B "$MG" "${ROOTS[@]}" --out="$OUT/graph" --class=IN,CO > "$OUT/graph.txt" 2> "$OUT/graph.err"; echo "G model-graph exit $? → $OUT/graph.txt"; sed -n '2,8p' "$OUT/graph.txt"; grep '^\[' "$OUT/graph.txt"
else echo 'G NOT-RUN (python3 or model-graph.py absent): the dbt lane is read by hand from 1a, 2a, 4a, 6a, 9a and prints Live, unreviewed'; fi
# 0 THE LOAD SURFACE — 0a dbt/SQLMesh projects · 0b Spark/Python/SQL loaders · 0c CDC configs · 0d backfill entry points · 0e scheduled writers
h 0a; rg --files -g '**/dbt_project.yml' -g '**/sqlmesh.yaml' "${X[@]}" "${ROOTS[@]}"; ck 0a $?
h 0b; rg -l -U --pcre2 '\b(?:pyspark|SparkSession|spark\.read|\.write\.mode\(|to_sql\(|read_sql\w*\(|executemany\(|copy_expert\(|(?i:merge\s+into\s+[\w."`]+(?:\s+(?:as\s+)?\w+)?\s+using\b|copy\s+[\w."]+\s*(?:\([^)]*\)\s*)?from\s+(?:stdin|\x27|\$|:)))' "${CODE[@]}" "${X[@]}" "${XM[@]}" "${ROOTS[@]}" | tee "$OUT/ETL.txt" | wc -l; ck 0b ${PIPESTATUS[0]}
h 0c; rg -l --pcre2 'io\.debezium|\bdebezium\b|"connector\.class"|^\s*connector\.class\s*[=:]|_fivetran_deleted|_ab_cdc_deleted_at' -g '*.{json,yml,yaml,properties,tf,py,sql}' "${X[@]}" "${ROOTS[@]}" | tee "$OUT/CDC.txt"; ck 0c ${PIPESTATUS[0]}
h 0d; rg -n --pcre2 '(?:dbt\s+(?:run|build)\b[^\n]*--full-refresh|airflow\s+dags\s+backfill|\bdagster\s+\w*\s*backfill|--event-time-(?:start|end)|\bbackfill\w*\s*[:=(])' -g '*.{yml,yaml,sh,py,toml,json,cfg}' -g '**/Makefile' -g '**/Dockerfile*' -g '**/crontab*' "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"; ck 0d ${PIPESTATUS[0]}
SCH='@(?:Cron|Interval|Timeout|Processor|Scheduled)\b|\bcron\.schedule\(|\bnew\s+CronJob\(|\bschedule\.(?:every|scheduleJob)\b|@(?:shared_task|periodic_task)\b|\bbeat_schedule\b|\bsidekiq_options\b|\bShouldQueue\b|->(?:daily|hourly|everyMinute|cron)\(|\bfrom\s+airflow\b|@(?:dag|asset|flow)\b|\bDAG\('
h 0e; rg -l --pcre2 "$SCH" "${CODE[@]}" "${X[@]}" "${XM[@]}" "${ROOTS[@]}" | tr '\n' '\0' | xargs -0 -r rg -l -i --pcre2 '\b(?:insert\s+into|upsert\w*|on\s+conflict|merge\s+into|to_sql\(|bulk_create|insert_all|upsert_all|createMany|native(?:Insert|Update|Delete)|persist(?:AndFlush)?\(|executemany|update\s+"?\w+"?\s+set)\b|\.save\(' | sort -u > "$OUT/SW.txt"; wc -l < "$OUT/SW.txt"
cat "$OUT/SW.txt" "$OUT/ETL.txt" | sort -u > "$OUT/LOAD.txt"; F=(); while IFS= read -r f; do F+=("$f"); done < "$OUT/LOAD.txt"; [ ${#F[@]} -gt 0 ] || F=("$OUT/none")
# 1 IN-1 watermark — 1a dbt hand lane (strict > on max()); 1b loaders: a timestamp compared with > to a bound parameter or a max() (read the lookback)
h 1a; rg -n --pcre2 '(?i)(?<![<=>-])>\s*\(\s*select\s+(?:coalesce\s*\(\s*)?max\s*\(' -g '*.sql' "${X[@]}" "${ROOTS[@]}"; ck 1a $?
h 1b; rg -n -U --pcre2 '(?i)\b\w*(?:updated|modified|changed|synced|loaded|created|event|ingested)_?at\b["`\]]?\s*>\s*(?:\$\d+|\?|%s|%\(\w+\)s|:\w+|\$\{|\{\w*\}|\(\s*select\s+(?:coalesce\s*\(\s*)?max\()|\b(?:updatedAt|modifiedAt|syncedAt|createdAt)\s*:\s*\{\s*\$?gt\s*:|\b\w+_at__gt\s*=' "${F[@]}" | "${NC[@]}"; ck 1b ${PIPESTATUS[0]}
# 2 IN-2 no merge key — 2a dbt hand lane: incremental models with no unique_key; 2b INSERT…SELECT with no ON CONFLICT / MERGE; 2c append writers
#   (2c spans lines: pandas `to_sql(` is usually multi-line and nests `get_engine()` — the old one-line `[^)\n]*` form found 0 of 2 such calls in a sample, 2026-10-19; data-pipelines 1 → 1)
h 2a; rg -l --pcre2 'materialized\s*=\s*["\x27]incremental' -g '*.sql' "${X[@]}" "${ROOTS[@]}" | tr '\n' '\0' | xargs -0 -r rg --files-without-match --pcre2 '\bunique_key\b|insert_overwrite|microbatch'
h 2b; rg -n -U --pcre2 '(?i)\binsert\s+into\s+[\w."`]+(?:\s*\([^)]{0,400}\))?\s*select\b(?![^;]{0,1200}?\bon\s+(?:conflict|duplicate\s+key)\b)' "${F[@]}" | "${NC[@]}"; ck 2b ${PIPESTATUS[0]}
h 2c; rg -n -U --pcre2 '\bto_sql\((?:[^()]|\([^()]*\))*?\bif_exists\s*=\s*["\x27]append|\.write\.mode\(\s*["\x27]append|\binsertInto\(|"?insert\.mode"?\s*[:=]\s*"?insert\b' "${CODE[@]}" -g '*.{json,properties,yml,yaml}' "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"; ck 2c ${PIPESTATUS[0]}
# 3 IN-3 load-time clock — 3a filters on load/processing columns; 3b watermark state written (read whether before the sink commits); 3c naive watermark types
LC='(?:_?loaded_at|_etl_\w+|ingested_at|inserted_at|_fivetran_synced|_airbyte_extracted_at|_sdc_batched_at|_load_ts)'
h 3a; rg -n --pcre2 "(?i)\\b$LC\\b[\"\`]?\\s*>=?\\s*" -g '*.sql' "${X[@]}" "${F[@]}" "${ROOTS[@]}" | "${NC[@]}" | sort -u; ck 3a ${PIPESTATUS[0]}
h 3b; rg -n --pcre2 '(?i)\bset_?watermark\w*\(|\bVariable\.set\(|\b(?:state|watermarks?|cursors?|checkpoints?|sync\w*)\.set\(|\.set\(\s*["\x27][\w:-]*(?:sync|watermark|cursor|last|checkpoint)|\b(?:last_?(?:run|sync|synced|loaded)\w*|watermark|high_?water\w*)\s*=\s*(?!=|(?:await\s+)?[\w.]*\.get\w*\()|\bupdate\s+"?\w*(?:state|watermark|cursor|checkpoint|sync)\w*"?\s+set\b' "${F[@]}" | "${NC[@]}"; ck 3b ${PIPESTATUS[0]}
h 3c; rg -n --pcre2 '(?i)\btimestamp_ntz\b|\btimestamp\s+without\s+time\s+zone\b|::timestamp\b(?!tz)|\bDATETIME\b' -g '*.sql' "${X[@]}" "${XM[@]}" "${ROOTS[@]}" | "${NC[@]}" | head -50; ck 3c ${PIPESTATUS[0]}
# 4 IN-4 deletes — 4a snapshots with no hard-delete handling (hand lane); 4b MERGE files with no delete branch (read each)
h 4a; rg -l --pcre2 '\{%-?\s*snapshot\s+\w+|^\s*relation:\s' -g '**/snapshots/**' "${X[@]}" "${ROOTS[@]}" | tr '\n' '\0' | xargs -0 -r rg --files-without-match --pcre2 '\bhard_deletes\b|\binvalidate_hard_deletes\b'
h 4b; rg -l -U --pcre2 '(?i)\bmerge\s+into\s+[\w."`]+(?:\s+(?:as\s+)?\w+)?\s+using\b' "${CODE[@]}" "${X[@]}" "${XM[@]}" "${ROOTS[@]}" | tr '\n' '\0' | xargs -0 -r rg --files-without-match --pcre2 '(?i)\bwhen\s+matched\s+and\b[^\n]{0,160}\b(?:op|_op|__op|_change_type|is_deleted|__deleted|_fivetran_deleted|deleted_at|_deleted)\b|whenMatchedDelete|\bthen\s+delete\b'
# 5 IN-5 non-deterministic dedup — one order key in row_number(), dropDuplicates/drop_duplicates, distinct on
h 5; rg -n -U --pcre2 '(?i)\brow_number\s*\(\s*\)\s*over\s*\(\s*partition\s+by\s+[^)]*?\border\s+by\s+[^,()]+(?:\([^()]*\))?\s*(?:asc|desc)?\s*(?:nulls\s+(?:first|last)\s*)?\)|\.(?:dropDuplicates|drop_duplicates)\(|\bdistinct\s+on\s*\(' "${CODE[@]}" "${X[@]}" "${XM[@]}" "${ROOTS[@]}" | "${NC[@]}"; ck 5 ${PIPESTATUS[0]}
# 6 IN-6 schema drift — 6a dbt hand lane (incremental, no on_schema_change); 6b Spark landing reads with no mergeSchema
h 6a; rg -l --pcre2 'materialized\s*=\s*["\x27]incremental' -g '*.sql' "${X[@]}" "${ROOTS[@]}" | tr '\n' '\0' | xargs -0 -r rg --files-without-match 'on_schema_change'
h 6b; rg -l --pcre2 'spark\.read\.(?:parquet|format\(\s*["\x27](?:parquet|json|avro))' "${F[@]}" | tr '\n' '\0' | xargs -0 -r rg --files-without-match 'mergeSchema'
# 7 IN-7 overwrite scope — overwrite writers with no dynamic mode / replaceWhere; truncate-and-reload in loaders
h 7a; rg -l --pcre2 '\.mode\(\s*["\x27]overwrite["\x27]\s*\)|\bINSERT\s+OVERWRITE\b' "${F[@]}" | tr '\n' '\0' | xargs -0 -r rg --files-without-match --pcre2 'partitionOverwriteMode["\x27,\s]+(?:["\x27])?dynamic|replaceWhere'
h 7b; rg -n --pcre2 '(?i)\btruncate\s+(?:table\s+)?[\w."`]+|\bif_exists\s*=\s*["\x27]replace' "${F[@]}" | "${NC[@]}"; ck 7b ${PIPESTATUS[0]}
# 8 IN-8 backfill — 0d entry points above; read each against the target's strategy (append target + range re-run = double count)
# 9 IN-9 microbatch — hand lane: microbatch models (read event_time, begin, batch_size, unique_key / partition_by per adapter)
h 9a; rg -n --pcre2 'incremental_strategy\s*[=:]\s*["\x27]?microbatch' -g '*.{sql,yml,yaml}' "${X[@]}" "${ROOTS[@]}"; ck 9a $?
# 10 IN-10 CDC landing — delete/tombstone handling, TOAST placeholder, sink insert mode, include lists; PK-less tables (migrations IN scope)
h 10a; [ -s "$OUT/CDC.txt" ] && tr '\n' '\0' < "$OUT/CDC.txt" | xargs -0 rg -H -n --pcre2 'ExtractNewRecordState|delete\.handling\.mode|drop\.tombstones|delete\.tombstone\.handling\.mode|tombstones\.on\.delete["\x27]?\s*[:=]\s*["\x27]?false|unavailable\.value\.placeholder|insert\.mode|table\.include\.list|snapshot\.mode'
h 10b; rg -n --pcre2 '(?i)\breplica\s+identity\b' -g '*.{sql,ts,js,py,rb,php}' "${ROOTS[@]}"; ck 10b $?
```

### Measured (2026-10-19)

- **Scratch sample** (a dbt-postgres 1.9 project — 5 models, 1 SQL snapshot, 3 source tables; three Airflow DAG files, a Dagster and a Prefect file, two Spark jobs, a pandas loader, a Debezium source + JDBC sink, a Nest `@Cron` sync job): under 1 s, 0 `rg` errors, `find -newer` empty. G: model-graph exit 0, 6 `IN` candidates — strict `>` on `_loaded_at` with no lookback (§1 + §3), no `unique_key` (§2), a one-key `row_number()` (§5), `on_schema_change` default `ignore` (§6), the snapshot with no hard-delete handling (§4); the `>=` + `interval '3 days'` model with a key stays quiet (precedent § 2). 0e 3 scheduled writers. 1b 2 (the pandas `> %s`, the Nest `> $1`). 2b 4 statements (two `DELETE … ; INSERT … SELECT` pairs — the delete is the control, read it — and two bare inserts). 2c 2 (`to_sql(if_exists="append")`, the sink's `insert.mode`). 3b 3 lines (both watermarks advanced before the read). 5 2 (`row_number()`, `dropDuplicates(["order_id"])`). 7a 1 (the job with `partitionOverwriteMode=dynamic` is excluded). 10a 4 lines.
- **Fixtures** (26 trees under `benchmarks/fixtures/`): 0 `rg` errors; G exits 4 on every tree (no dbt project → the dbt lane prints N/A); 0a 0. Only `ds-defects` has a load surface: 0c 4 (three Debezium connectors), 1b 1 (an event-store projector cursor `created_at > $1` → distributed-systems `dual-write-scan` §6), 2b 3 statements (a statement-close and a daily-volume rollup → `derived-data-scan`; a reconciliation adjustment → read), 3b 1 (an outbox feed cursor → distributed-systems), 10a 8 lines (two outbox connectors with `tombstones.on.delete` false → precedent § 18 and distributed-systems; one table-CDC connector with `snapshot.mode` initial — the §10 read). 0e lists 15 / 4 / 1 scheduled writers in `ds-defects` / `backend-defects` / `slow-marketplace`.
- **Real repo, read-only** (a NestJS 11 + Fastify + MikroORM 6 + Postgres monorepo, `ROOTS=(apps/api/src)`; 0.6 s; nothing written): G exit 4; 0a–0d 0 (no dbt, Spark, CDC or backfill entry point). 0e 5 — four queue consumers (→ distributed-systems `messaging-scan`) and a usage processor whose find-then-insert sits behind a unique `(tenant, day)` constraint (precedent § 20). 1b 0, 3b 0. 5: 4 `distinct on` in report queries (latest row per order — the order-by is the read). A first draft of 0b and 4b matched the phrase "merge into" in a comment; both now require `MERGE INTO … USING`. A first draft of 0e found 0 scheduled writers: the jobs write through MikroORM (`persist`, `nativeUpdate`, `.save(`), now in the write set.
- **The warehouse lanes are fixture-measured only**: no real dbt, Spark or Debezium repo is in the program's allowed set (eval O-2). Every dbt/Spark/CDC count above comes from the scratch sample until one is granted.

## The 3,000-hit path

1. **Build the graph once** (G). On a dbt project every `IN-*` row arrives with its effective config and source; group by **(config source, class)** — one `+on_schema_change` missing in `dbt_project.yml` is one decision for every model under that path, and the fix is one line there.
2. **Intersect** the loader greps with `$OUT/LOAD.txt` (scheduled writers + ETL files); a watermark word outside a scheduled writer is noise (eval § 3.4: 81 watermark lines → the rollup files on a real repo).
3. **Rank** by consumption: models with exposures or `access: public` and tables an export or a BI tool reads first; staging last.
4. **Read the top 30 groups fully** (the grain, the source mutation pattern, the latest arrival).
5. **Prefer the replay oracle at scale**: build the project into duckdb from committed seeds (`dbt build --target` a duckdb profile in `$OUT`) and run V540 over every incremental at once — two runs, identical counts and checksums.
6. **Ledger the rest** as `Live, unreviewed (k groups / n hits)` with the command that continues them. Never `checked`.

## Scans for

Severity assumes the target feeds a consumer (a mart, an export, a dashboard, a model contract). One step lower for a staging-only or internal-scratch table; a row loss in a table feeding money or a regulatory report stays high.

| § | Class (`DE-` id) | Default severity | Oracle |
|---|---|---|---|
| 1 | IN-1 strict `>` watermark or no lookback | high (`>` without lookback) / medium (`>=` without lookback) | V541, V542 |
| 2 | IN-2 no merge key, a key that is not the grain, or a nullable key | high | V540 |
| 3 | IN-3 load-time clock, wrong-zone watermark, watermark advanced before the write commits | medium | V541 |
| 4 | IN-4 source deletes never reach the target | high | V543 |
| 5 | IN-5 non-deterministic dedup | medium | V544 |
| 6 | IN-6 schema drift silently dropped or fatal late | medium | V545 |
| 7 | IN-7 overwrite wipes more than the run's partitions | high | V546 |
| 8 | IN-8 backfill re-appends history or runs in place beside the schedule | high | V549, V550 |
| 9 | IN-9 microbatch misconfiguration | high | V541 |
| 10 | IN-10 CDC landing loses, keeps or corrupts rows | high | V543, V540 |

### 1. Strict `>` watermark, or no lookback

```
BAD:   {% if is_incremental() %} where updated_at > (select max(updated_at) from {{ this }}) {% endif %}
       rows = db.query('select * from orders where updated_at > $1', [lastSyncedAt])
GOOD:  where updated_at >= (select max(updated_at) from {{ this }}) - interval '3 days'   -- with a merge on the key
```
**Read the code:** Which column is the watermark — event time, an update stamp the source sets, or a monotonic sequence? Can two rows share its value and commit at different times (a batch insert, a clock at second precision)? What is the latest a row can arrive (replication lag, a mobile client, a partner file)? Is there a lookback, and is the load a merge (so the lookback re-reads without duplicating — precedent § 2)? **Finding when** a tie or a late row inside the documented arrival window is never loaded. A strictly monotonic sequence id (an LSN, a bigserial the loader reads after commit) is not this class. **Severity:** high for `>` with no lookback, medium for `>=` with no lookback on an event-time column. **Proof:** V542 (a row with the watermark's timestamp inserted after the run is present after the next) and V541 (a row older than the watermark, inside the lookback, is present).

### 2. No merge key, a key that is not the grain, or a nullable key

```
BAD:   {{ config(materialized='incremental') }}                          -- adapter default, no unique_key
       {{ config(materialized='incremental', incremental_strategy='append', unique_key='id') }}   -- append ignores the key
       INSERT INTO mart.orders SELECT * FROM raw.orders WHERE day = %s    -- a retry inserts the day again
       df.to_sql('orders_copy', conn, if_exists='append')
GOOD:  unique_key='order_id' + a not_null and a unique test on order_id · ON CONFLICT (order_id) DO UPDATE
       DELETE FROM mart.orders WHERE day = %s; INSERT … (one transaction) · insert_overwrite on the partition
```
**Read the code:** What is the grain, and is the key exactly it (a composite key for a line-level table)? Does the source update rows in place (`updated_at`, a CDC op column) — then `append` duplicates (precedent § 1 holds only for immutable events deduplicated downstream)? Can any key column be NULL (no `not_null` test, an outer join upstream)? A NULL never matches in a merge. Is a declared primary key relied on outside Postgres (metadata only)? **Finding when** re-processing the same batch, a retry, or the lookback window inserts a row twice. **Proof:** V540 — run the same batch twice on a scratch target: `count(*)` and a row checksum unchanged; a NULL-key row yields no duplicate.

### 3. Load-time clock, wrong-zone watermark, or a watermark advanced too early

```
BAD:   where _loaded_at > (select max(_loaded_at) from {{ this }})        -- the warehouse's clock, not the event's
       state.set('last_synced_at', now()); rows = read('… where updated_at > %s', last)   -- advanced before the write
GOOD:  filter on the event/update time with a lookback; advance the watermark to max(read rows) after the commit
```
**Read the code:** Which clock wrote the filter column — the source system, the loader, or the warehouse? A load-time column (`_loaded_at`, `_fivetran_synced`, `_airbyte_extracted_at`) skips rows the extractor delivers out of order. Is the watermark read from the target and compared with a source stamp written by another clock (skew)? Is one side `timestamp_ntz` / `without time zone` and the other UTC? Is the watermark advanced before the sink statement commits, so a crash between the two loses the window? Does a wall-clock function (`now()`, `current_date`) bound the window, so a replay selects a different set? **Finding when** a row the source committed is skipped because of the clock, the zone or the ordering. **Proof:** V541 with the row's two stamps skewed; and a fault injected between the state write and the sink — the rerun loads the window.

### 4. Source deletes never reach the target

```
BAD:   {% snapshot customers_snapshot %} {{ config(unique_key='id', strategy='timestamp', updated_at='updated_at') }}
       MERGE INTO t USING s ON t.id = s.id WHEN MATCHED THEN UPDATE … WHEN NOT MATCHED THEN INSERT …   -- no delete
GOOD:  hard_deletes='invalidate' (or 'new_record'); staging filters _fivetran_deleted;
       WHEN MATCHED AND s.op = 'd' THEN DELETE
```
**Read the code:** Does the source hard-delete (an erasure job, an admin delete, a retention purge)? Does the extractor deliver deletes (a CDC op, a soft-delete column, a full snapshot compare)? Does every downstream model filter or apply them? On dbt ≥ 1.9 is `hard_deletes` set; on ≤ 1.8, `invalidate_hard_deletes`? A source that never deletes is precedent § 16. **Finding when** a row deleted at the source stays current in a consumed table. Personal data kept after erasure is also `pii-copy-scan` §2's row — cite it there. **Proof:** V543 — delete a source row, run: absent or flagged (`dbt_valid_to` set) in the target.

### 5. Non-deterministic dedup

```
BAD:   row_number() over (partition by event_id order by occurred_at)          -- ties: a different winner per run
       df.dropDuplicates(["order_id"])                                         -- keeps an arbitrary row
GOOD:  row_number() over (partition by event_id order by occurred_at desc, _ingested_seq desc, payload_hash)
```
**Read the code:** Can two rows tie on the order keys? Do tied rows differ in any other column? `dropDuplicates` on full-row duplicates is deterministic in effect (precedent § 15). **Finding when** a tie on the order columns between rows that differ elsewhere is possible. **Proof:** V544 — shuffle the input order (seeded) ×10: the output checksum is identical.

### 6. Schema drift silently dropped, or fatal late

**Read the code:** Is the effective `on_schema_change` `ignore` (default) on an incremental whose source gains columns (a SaaS extractor, an event payload)? Is it `sync_all_columns` on a large or partitioned table (columns dropped, types changed without a failure)? Does an enforced contract require `append_new_columns` or `fail`? On Spark, does a landing path hold several schema versions read without `mergeSchema`? `view` and `table` models rebuild each run (precedent § 5). **Finding when** an added source column never reaches a consumed table and nothing fails, or a removed column fails the nightly with no migration path. **Proof:** V545 — add a source column, run incrementally: the run fails or carries it per the declared policy.

### 7. An overwrite wipes more than the run's partitions

```
BAD:   df.write.mode("overwrite").partitionBy("day").parquet(path)          -- STATIC: every partition deleted
       TRUNCATE TABLE mart.orders; INSERT INTO mart.orders SELECT …         -- readers see an empty table between
GOOD:  spark.sql.sources.partitionOverwriteMode=dynamic · Delta .option("replaceWhere", "day = '…'") · swap a staged table
```
**Read the code:** Is `partitionOverwriteMode` set to `dynamic` in the session, the job conf or the write options? Is the table Delta with `replaceWhere`, or Hive serde (always dynamic — precedent § 28)? For dbt `insert_overwrite`, which scope does the adapter replace with and without `partition_by` / `partitions`? Do readers query during a truncate-and-reload? **Finding when** one run's write deletes partitions it did not produce, or exposes an empty table to readers. **Proof:** V546 — write two partitions, overwrite one: the other's checksum is unchanged.

### 8. Backfill re-appends history, or runs in place beside the schedule

**Read the code:** For each 0d entry point, what is the target's strategy? A range re-run into an `append` target double-counts; `--full-refresh` on an incremental over a retention-limited source rebuilds without the history (is `full_refresh: false` pinned — precedent § 9)? Does the backfill write the live table while the schedule runs (`max_active_runs`, no pause)? **Finding when** re-running a loaded range changes counts or loses history. **Proof:** V550 — backfill the same range twice: identical aggregates; V549 — the run's output depends only on its interval.

### 9. Microbatch misconfiguration

**Read the code:** Are `event_time`, `begin` and `batch_size` set? Does every `ref()` parent declare `event_time` (else it is read in full for every batch)? On Postgres (merge) is there a `unique_key`; on BigQuery/Spark/Databricks (insert-overwrite) a `partition_by`? Is `lookback` enough for the source's late arrival? Microbatch models need no `is_incremental()` filter (precedent § 7). **Finding when** a missing key or parent `event_time` breaks the backfill, re-scans the parent, or duplicates rows. **Proof:** V541 over two batches with a late row; `dbt run --event-time-start/--event-time-end` twice → identical counts.

### 10. CDC landing loses, keeps or corrupts rows

```
BAD:   "transforms.unwrap.type": "io.debezium.transforms.ExtractNewRecordState", "transforms.unwrap.drop.tombstones": "true",
       "transforms.unwrap.delete.handling.mode": "drop"          -- deletes never reach the sink
       JDBC sink "insert.mode": "insert" over a change topic       -- updates and snapshot restarts append duplicates
GOOD:  rewrite deletes to a flag (or keep tombstones for a compacted topic) + an upsert sink keyed by PK + source LSN
```
**Read the code:** Which Debezium version (the image tag — 3.2 renamed the delete options)? Are deletes dropped, rewritten to a flag, or delivered as tombstones the sink applies? Does every table in `table.include.list` have a primary key or `REPLICA IDENTITY FULL` (grep 10b and the migrations)? Are TOASTed columns handled (`unavailable.value.placeholder` would overwrite the real value)? Does the sink upsert by key with source ordering, so a restarted snapshot does not append the table again? `tombstones.on.delete=false` with a non-compacted topic whose sink consumes `op=d` is precedent § 18. **Finding when** a source delete, update or snapshot restart leaves the landing table wrong. **Proof:** V543 (delete → absent or flagged; connector restart mid-snapshot → no duplicate key) and V540.

## Output

```
incremental-load-scan — lanes: dbt 1.9.4 (requirements.txt) + dbt-postgres; airflow 3.0.2; pyspark 3.5.1; debezium (cdc/*.json); nest @Cron
Graph: $OUT/graph — model-graph exit 0 · 5 models · 2 incremental · 1 snapshot · 6 IN candidates

Findings: 2 · Unconfirmed: 2

1. analytics/models/marts/fct_pageviews.sql:7                    [report-with-fix]   high
   §1 IN-1 + §3 IN-3 — `_loaded_at > (select max(_loaded_at) from {{ this }})`: a load-time column, strict, no lookback;
   effective unique_key none, strategy adapter-default (fct_pageviews.sql:1 config()). V542 on duckdb: the tie row is missing.
   Fix: filter on occurred_at >= max(occurred_at) - interval '2 days', unique_key='event_id'.
2. analytics/snapshots/orders_snapshot.sql:1                      [report-with-fix]   high
   §4 IN-4 — hard_deletes `ignore` (default, dbt 1.9.4); orders are hard-deleted by the erasure job (apps/…/erase.ts:40).
   V543: deleted order still has dbt_valid_to null. Fix: hard_deletes='invalidate'.
3. etl/loader.py:4                                                [report-flagged]    medium
   §3 IN-3 — last_synced_at advanced before the read and the append. Unproven link: the scheduler's retry on failure.
   Settling: V541 with a fault after line 4.
4. cdc/orders-source.json:4                                       [report-flagged]    high
   §10 IN-10 — delete.handling.mode drop + drop.tombstones true: deletes never reach the lake. Unproven link: the
   Debezium version (no image tag in the repo). Settling: V543 on a scratch connector.

Routed: 1 → distributed-systems dual-write-scan §6 (outbox relay cursor) · 1 → derived-data-scan §1 (rollup counter)
Verification: 14 candidates → 2 findings · 2 unconfirmed · 3 hardening · 7 refuted (self-verified; precedents § 2, § 5, § 15)

Class coverage:
  §1 watermark          checked (G 1 + 1b 3 → 1 finding; 2 refuted: >= with lookback and a merge)
  §2 merge key          checked (G 2 + 2b 2 + 2c 3 → 0 findings; the DAG deletes the day first)
  §3 load clock         checked (G 1 + 3b 2 → 1 finding, 1 unconfirmed)
  §4 deletes            checked (G 1 + 4b 0 → 1 finding)
  §5 dedup              checked (2 → 0 findings; event_id ties carry identical payloads, read)
  §6 schema drift       checked (G 1 → hardening: the source is a frozen export)
  §7 overwrite scope    checked (7a 1 → 0: the path is written once per day by one job — hardening)
  §8 backfill           checked (0d 1 → 0)
  §9 microbatch         N/A (no microbatch model)
  §10 CDC landing       checked (10a 4 lines → 1 unconfirmed)
```

`[report-with-fix]`: the oracle failed on a scratch target (duckdb, a container, a rolled-back transaction) or the fact is two-ended with the effective value's source quoted, and the fix is local to the model config, the loader or the connector. `[report-flagged]`: one link is unproven (the source's delete behaviour, the arrival window, the connector version, the scheduler's retry); the row names it and the settling oracle. The coverage block lists **all ten** §s on every run: `checked (k → m)`, `N/A (<reason>)`, `blocked (model-graph exit 2 | <grep>: rg exit 2)`, `→ <owner> (k)` or `Live, unreviewed (k)`.

## False positives / precedents

The verifier reads [`precedents/data-engineering.md`](../../../../snippets/precedents/data-engineering.md) and [`refute-before-report.md`](../../../../snippets/refute-before-report.md) § 4. The ones this skill meets most:
- § 1 an append-only immutable event table with no key (flips back when the source has `updated_at` or a CDC op); § 2 strict `>` + lookback + merge; § 5 `on_schema_change` on `view`/`table`; § 7 microbatch with no `is_incremental()`; § 8 `insert_overwrite` with no `unique_key`; § 9 `full_refresh: false` on huge incrementals.
- § 15 `dropDuplicates` on full-row duplicates; § 16 `hard_deletes: ignore` on a source that never deletes; § 18 `tombstones.on.delete=false` on a non-compacted topic whose sink applies `op=d`; § 28 Delta `replaceWhere` / Hive serde overwrite.

## When to run

- On any diff that touches a dbt model config or `is_incremental()` block, `dbt_project.yml`, a snapshot, a loader or sync job, a Spark write, a connector config, a backfill script — `/audit-data-quality`'s load signal.
- On every whole-repo `/audit-data-quality`, reporting all ten §s; before `/backfill-plan` (its loader gate reads §2, §7 and §8).

## Halt conditions

- **Do not report a high row as confirmed until a separate verifier has refuted it.** That verifier is not the run that found it, gets the effective config with sources, the `file:line` and the source row — never this run's reasoning — and passes the row through [`refute-before-report.md`](../../../../snippets/refute-before-report.md) §1–§8 with [`precedents/data-engineering.md`](../../../../snippets/precedents/data-engineering.md). Under `/audit-data-quality` that is `@warehouse-modeler` in refute mode, then `@verifier` mode `refute` on every BLOCKER and HIGH; standalone, apply the protocol as a second pass and tag the rows `self-verified`. List the rest as Unconfirmed, Hardening or Refuted; never drop them.
- **Never run a load, a `dbt run/build`, a backfill or a connector against a shared or production target.** Oracles run on duckdb (a profile written in `$OUT`, `DO_NOT_TRACK=1`, `DBT_SEND_ANONYMOUS_USAGE_STATS=False`, `DBT_ENGINE_SEND_ANONYMOUS_USAGE_STATS=false`, `--profiles-dir "$OUT"`), a throwaway container, or a rolled-back transaction; a probe that cannot be isolated is `NOT-RUN`.
- **This skill writes nothing into the audited repo**: no model, test, config or fixture. `git status --porcelain` and `find <root> -newer "$OUT/graph.txt"` are empty at the end; a fix is `/data-fix`'s.
- **Halt before §1–§10 on a dbt project when model-graph is blocked** (exit 2): every dbt row prints `blocked`, never `checked`. Exit 4 prints the dbt lane `N/A (no dbt project)`.
- **No `[report-with-fix]` without the V-oracle failing on a scratch target**, or a two-ended static fact whose effective value and source are quoted. "Incremental without a key" is a candidate until the source's mutation pattern is read.
- **No fix may hide the class:** never add `distinct` to mask duplicates, never widen a lookback without a merge, never switch to a full refresh of an unrecoverable history.
- **Do not grade a class another skill owns** (§ Boundaries). **Do not print "the loads look idempotent"** without the coverage block.

## Related

- `/audit-data-quality` dispatches this skill and folds its coverage block into the class ledger; `@warehouse-modeler` judges IN rows; `/data-fix` closes them with the failing-first oracle; `/backfill-plan` reads §2/§7/§8 for its loader gate.
- This pack: `orchestration-scan` (the retry and the schedule around a load), `data-test-scan` (the key's `not_null`/`unique` tests), `derived-data-scan` (in-app rollups), `pii-copy-scan` §2 (erasure in snapshots), `warehouse-scan-audit` §2 (merge cost), `lineage-trace` (it reads the edges `scripts/model-graph.py` here writes).
- distributed-systems `messaging-scan` / `dual-write-scan`, backend `job-lifecycle-scan`, database `migration-safety` (PK-less tables), when those packs are installed.

## Sources

From the data-engineering research (2026-10-19): dbt docs — incremental models and strategies (updated 2026-09-16), microbatch, snapshots, constraints, full_refresh, usage stats and the `DBT_ENGINE_` prefix; Spark 4.2.0 configuration (`partitionOverwriteMode`, `mergeSchema`); Debezium Postgres connector 3.7 docs and the 3.2.0.Final release notes (2025-07-09); dbt-duckdb 1.11.0 / DuckDB 1.5.6 (MERGE INTO since 1.4.0) as the offline oracle. Class ids DE-A-1…35 and DE-B-60…64 as catalogued there. Counts are from read-only runs of the block above. Paraphrased; no text reproduced beyond identifiers.

Files in this skill

  • SKILL.md41.7 KB
  • scripts/model-graph.py99.4 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.