Skip to content
Back to skills

Lineage Trace

ASecurity

Model graph without dbt: hard-coded relations, layer breaks, undeclared readers, orphans; up/downstream trace of a node.

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

Works with

  • api

Security analysis

A100/100

Scanned October 20, 2026

npx -y skills add adnanmokhtar/refract --skill lineage-trace --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Lineage Trace?

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

Security grade badge for Lineage Trace
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/adnanmokhtar-lineage-trace/badge)](https://www.skillsdirectory.com/skills/adnanmokhtar-lineage-trace)

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: lineage-trace
description: "Model graph without dbt: hard-coded relations, layer breaks, undeclared readers, orphans; up/downstream trace of a node."
kind: skill
pack: data-engineering
allowed-tools: [Read, Grep, Glob, Bash]
---

# Skill: lineage-trace

## Premise

Nobody changes a warehouse model without knowing its readers on purpose; they do it because finding the readers is tedious, and the graph they would ask is wrong in three predictable ways. A model that reads `analytics_raw.payments` instead of `{{ source('raw', 'payments') }}` is an edge the framework never sees: the build order can run it before its parent, and a trace from `payments` misses it. A mart that reads a raw source directly, or a staging model that joins two sources, ties consumers to raw shapes and duplicates logic. And the readers that matter most — a dashboard, a scheduled export, an application query, a reverse-ETL sync — are outside the project unless an `exposures:` entry names them. A lineage report that silently omits them converts an unknown into false confidence.

This skill owns research ids LN-1…LN-4 (`research-data-engineering.md` § 1.5), shipped as `DE-LN-<n>`: **whether the model graph is complete and layered, and who reads each node.** It also answers the trace question the other skills ask — *who is downstream of this column, model or table* — with real paths, never "some dashboards".

**Every finding cites the model `<file:line>` + the relation as written + the edge the graph has (or lacks) + the layer of each end + the readers found inside and outside the project + the oracle that would settle it + the fix (`ref()`/`source()`, a staging model, an `exposures:` entry, a retirement).** A grep hit is a candidate. "The lineage is complete" is never printed; a trace with an unread consumption layer is printed `PARTIAL — <what was not traced>`. Verification follows [`refute-before-report.md`](../../../../snippets/refute-before-report.md) with [`precedents/data-engineering.md`](../../../../snippets/precedents/data-engineering.md); the oracle is [`proof-oracles.md`](../../../../snippets/proof-oracles.md) § Data engineering, V559.

## The rule

A candidate is a finding when all four hold:

1. **The node is built and scheduled.** The model is enabled and selected by a job (CI, cron, dbt Cloud, a DAG). An `analyses/` file or a disabled model fails here.
2. **The relation is read as written.** The raw `schema.table`, the `source()`/`ref()` call, the layer from the path; for readers outside the project, the file that names the physical table.
3. **No existing control, across files.** A macro that resolves the relation through `source()`/`ref()`, a CTE name, `{{ this }}`, `information_schema` (precedent § 24); a declared exposure; a source read by a non-dbt job cited by file.
4. **The oracle fired, or the fact is two-ended.** V559 — the graph's edges per model equal the relation mentions in its SQL (a `sqlglot` table walk, or `dbt ls --select +<model>` on a duckdb profile) — or the static fact cites the raw relation and the missing edge.

## Boundaries

- **Whether a change breaks the readers found here** → `contract-diff` (this pack); **whether the data in a node is right** → `grain-probe`, `data-test-scan`; **cost of a view chain** → `warehouse-scan-audit` §4. This skill names *who*, not *whether*.
- **A metric defined twice** across models or in the BI layer → business `report-scan` §2 (when installed); this skill supplies the paths of both definitions.
- **Application-to-application data flow** (services calling each other, message topics) → distributed-systems; **the module dependency graph of application code** → code-quality `architecture-scan`. The in-app lane here maps only which application files name a derived or analytical table.

## Adapt to the codebase — the lanes

| Lane | Detect by | Graph source | Readers outside the graph |
|---|---|---|---|
| **dbt** (Core / Fusion, any adapter) | `dbt_project.yml` | `model-graph.py`: `ref()` (one- and two-argument), `source()`, raw `from/join schema.table`, Python models, snapshots, exposures; a committed `target/manifest.json` is a cross-check (`manifest-diff.tsv`) | `exposures:`; physical names searched in app code, BI definitions (LookML `sql_table_name`, Metabase/Superset exports), export jobs, reverse-ETL configs |
| **SQLMesh** | `MODEL (` in `*.sql` | model names in `FROM`; `sqlmesh dag` when installed | the same search |
| **Plain SQL / Python ETL** | `INSERT INTO … SELECT`, `to_sql(`, `read_sql(` in jobs | the table each job reads and writes (sqlglot when installed) | — |
| **In-app** (any ORM + raw SQL) | rollups, views and analytical tables in migrations | raw SQL `from/join/into/update` mentions per table | the files that name each derived table |
| **Runtime lineage** | `openlineage`, `dbt-ol`, `OPENLINEAGE_URL` | emitted events (never read here; noted as present) | — |

## Pre-flight

Run as **one** script from the repo root (bash 3.2-safe). Grep **N** feeds §N; G builds the graph. Every `rg` names its path; include globs first, exclusions last, every exclusion `**/`-rooted; path globs may stand together but are never combined with an extension glob. 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 dbt project root(s) and the app roots whose code may read the warehouse
OUT="${OUT:-$(mktemp -d "${TMPDIR:-/tmp}/de-ln.XXXXXX")}"; mkdir -p "$OUT/none"; NODE="${NODE:-}"   # NODE=model.fct_orders for a trace
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,.venv,venv,dbt_packages,logs}/**'
   -g '!**/{test,tests,__tests__,spec,specs,e2e,fixtures,__fixtures__,mocks,__mocks__,testdata}/**' -g '!**/*.{spec,test}.*' -g '!**/{docs,ai,.claude}/**' -g '!**/*.{md,mdx,lock}')
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=LN > "$OUT/graph.txt" 2> "$OUT/graph.err"; echo "G model-graph exit $? → $OUT/graph/edges.tsv"; sed -n '2,10p' "$OUT/graph.txt"; grep '^\[' "$OUT/graph.txt"
else echo 'G NOT-RUN (python3 or model-graph.py absent): §1 and §4 are read by hand from 1 and 4 and print Live, unreviewed'; fi
# 1 LN-1 hard-coded relations — the hand lane over model SQL (G's LN-1 rows are the same class with CTEs removed)
h 1; rg -n --pcre2 '(?i)\b(?:from|join)\s+(?!\{\{|information_schema\b|lateral\b|unnest\b)["`]?[a-z_]\w*["`]?\.["`]?[a-z_]\w*["`]?(?:\.["`]?[a-z_]\w*["`]?)?(?!\s*\()' -g '**/models/**/*.sql' -g '**/snapshots/**/*.sql' "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"; ck 1 ${PIPESTATUS[0]}
# 2 LN-2 layer direction — G rows (marts/intermediate reading source(); staging joining sources); 2a source() calls outside staging by path
h 2a; rg -n --pcre2 '\{\{\s*source\s*\(' -g '**/models/marts/**' -g '**/models/intermediate/**' -g '**/models/mart/**' "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"; ck 2a ${PIPESTATUS[0]}
# 3 LN-3 readers outside the graph — 3a exposures declared · 3b marts' physical names named outside the dbt project · 3c runtime lineage present
h 3a; rg -c --pcre2 '^\s*exposures\s*:' -g '*.{yml,yaml}' "${X[@]}" "${ROOTS[@]}"; ck 3a $?
[ -s "$OUT/graph/models.tsv" ] && awk -F'\t' 'NR>1 && $5=="marts" {sub(/^model\./,"",$2); print $2}' "$OUT/graph/models.tsv" | sort -u > "$OUT/MARTS.txt"
h 3b; [ -s "$OUT/MARTS.txt" ] && while IFS= read -r m; do r=$(rg -l -w "$m" -g '!**/models/**' -g '!**/snapshots/**' -g '!**/target/**' -g '!**/dbt_project.yml' "${X[@]}" "${ROOTS[@]}" | tr '\n' ' '); echo "$m → ${r:-no reader outside dbt}"; done < "$OUT/MARTS.txt"
h 3c; rg -l --pcre2 '\bopenlineage\b|\bdbt-ol\b|OPENLINEAGE_URL' -i "${X[@]}" "${ROOTS[@]}"; ck 3c $?
# 4 LN-4 orphans — G rows (unread non-mart models; declared sources never read); 4a schedules that select by name (read whether a 'dead' model is selected)
h 4a; rg -n --pcre2 '\bdbt\s+(?:run|build)\b[^\n]*(?:--select|-s)\s+[^\n]+' -g '**/.github/workflows/*.{yml,yaml}' -g '**/.gitlab-ci.yml' -g '**/dags/**' -g '**/Makefile' -g '**/*.sh' --hidden "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"; ck 4a ${PIPESTATUS[0]}
# IN-APP lane — which application files name each derived/analytical table in raw SQL (the reader list for rollups and views)
h app; rg -o -I --no-filename --pcre2 -r '$1' '(?i)\b(?:from|join)\s+"?((?:\w+_)?\w+_(?:daily|hourly|weekly|monthly|summary|stats|rollups?|agg|totals|facts|snapshots?|mv)|(?:mv|analytics|reporting|rpt)_\w+)"?\b' -g '*.{ts,js,py,rb,php,java,kt,go,sql}' -g '!**/{migrations,migration}/**' "${X[@]}" "${ROOTS[@]}" | sort | uniq -c | sort -rn | head -30
# TRACE (on demand): NODE's downstream and upstream closure from edges.tsv
[ -n "$NODE" ] && [ -s "$OUT/graph/edges.tsv" ] && python3 -I - "$OUT/graph/edges.tsv" "$NODE" <<'PY'
import sys, collections
down, up = collections.defaultdict(set), collections.defaultdict(set)
for line in open(sys.argv[1], encoding='utf-8').read().splitlines()[1:]:
    c, p, k, w = (line.split('\t') + ['', '', '', ''])[:4]
    down[p].add(c); up[c].add(p)
def walk(g, n):
    seen, st = set(), [n]
    while st:
        for x in g[st.pop()] - seen:
            seen.add(x); st.append(x)
    return sorted(seen)
print('downstream of', sys.argv[2], ':', ' '.join(walk(down, sys.argv[2])) or 'none in the graph')
print('upstream of', sys.argv[2], ':', ' '.join(walk(up, sys.argv[2])) or 'none')
PY
```

3b prints, per mart, the files outside the dbt project that name its physical table: `no reader outside dbt` on a mart with no exposure is an LN-3/LN-4 question for the read (a BI tool may read it through a connection the repo cannot see — `PARTIAL`, never "unused"). The model name stands for its physical name unless `alias:` or a custom schema changes it; read `models.tsv` for the alias column before trusting a miss.

### Measured (2026-10-19)

- **Fixture** (`benchmarks/fixtures/data-pipelines`, `ROOTS=(.)`; under 1 s): G 6 LN rows — two hard-coded `analytics_raw.*` relations, a mart reading `source()` directly, a staging model joining two sources, an unread staging model, a source nobody reads; 1 2 (the same raw relations); 2a 1; 3a 1 exposure; 3b 31 marts, of which the bank-feed mart and the CRM-sync dimension have undeclared outside readers, and three names collide with a DAG or table name — read; 4a 5 dbt selections. The project-level "no exposures" row does not fire because one exposure exists.
- **Real repo, read-only** (the NestJS monorepo, `ROOTS=(apps/api/src)`; 0.3 s): G exit 4 — §1, §2, §4 print `N/A (no dbt project)`. The in-app lane lists the order-day and quarter rollups (4 reading files each) and four `*_agg` CTE names inside report queries (CTEs, not tables — read); §3 prints `PARTIAL — no exposures; readers are application queries listed by the in-app lane`.
- **The dbt, SQLMesh and runtime-lineage lanes are fixture-measured only** (eval O-2).

## The 3,000-hit path

1. **Build the graph once** (G) and read `edges.tsv`: group LN-1 by the raw schema (`analytics_raw.*` in forty models is one decision — declare the source once, replace the relation everywhere).
2. **Run the oracle at scale** (V559): one `sqlglot` table walk over every compiled model (or `dbt ls --select +<model>` on a duckdb profile in `$OUT`), compared with `edges.tsv` per model; the difference is the LN-1 list.
3. **Rank** by consumption: marts with outside readers (3b) and exposures first; staging last.
4. **Read the top 30 groups fully**; ledger the rest as `Live, unreviewed (k groups / n hits)` with the command that continues them. Never `checked`.

## Scans for

| § | Class (`DE-` id) | Default severity | Oracle |
|---|---|---|---|
| 1 | LN-1 hard-coded relation instead of `ref()`/`source()` | medium | V559 |
| 2 | LN-2 layer direction violated | low | V559 |
| 3 | LN-3 readers undeclared (no exposures; outside readers invisible) | info (medium when a money or partner reader is found undeclared) | — (two-ended: the reader file and the missing exposure) |
| 4 | LN-4 orphans: scheduled models nobody reads; sources declared and unused | low | — (two-ended: the schedule and the absent reader) |

### 1. Hard-coded relation

```
BAD:   select … from analytics_raw.payments p join {{ ref('stg_orders') }} o on …
GOOD:  from {{ source('raw', 'payments') }} p   -- declared in _sources.yml; the graph and the build order see it
```
**Read the code:** Is the relation a CTE defined above, `{{ this }}`, `information_schema`, or resolved by a project macro (precedent § 24)? Is it a table another dbt project builds (then a cross-project `ref` or a declared source)? **Finding when** a real upstream relation is invisible to the graph, so the build order or a trace is wrong. **Proof:** V559 — the table walk of the compiled SQL lists a relation that `edges.tsv` lacks.

### 2. Layer direction

**Read the code:** Does a mart or intermediate model read `source()` directly (a staging model would own the renames and casts once)? Does a staging model join two sources? Is the project small enough that two layers are the convention (not a finding)? **Finding when** the same raw-shape logic is duplicated in two marts, or a mart breaks on a raw rename that staging should have absorbed. **Proof:** V559 with the layer of each edge's ends.

### 3. Readers undeclared

**Read the code:** For each mart, which files outside the project name it (3b) — an export job, an app query, a LookML view, a reverse-ETL config? Is each declared as an exposure? Does runtime lineage (OpenLineage) cover what the repo cannot? **Finding when** a reader that acts on money or sends data to a partner is found and undeclared — a breaking change would not list it. Otherwise print the trace `PARTIAL` with the gap named.

### 4. Orphans

**Read the code:** Is the "orphan" selected by a schedule (4a) and read by nothing (no model, no exposure, no outside file)? Is a declared source read by a non-dbt job (cite it)? **Finding when** a scheduled model or source has no reader anywhere the repo can show — spend and freshness alerts for nothing. Retirement is the owner's call; the row names the schedule and asks.

## Output

```
lineage-trace — lanes: dbt 1.9.4 (static graph; no manifest); in-app: postgres raw SQL; runtime lineage: none
Graph: $OUT/graph — 14 models · 3 snapshots · 6 sources · 29 edges (ref 17, source 9, raw 3) · 0 exposures

Findings: 1 · Unconfirmed: 1 · PARTIAL: consumption layer (no exposures; BI not in repo)

1. analytics/models/staging/stg_invoices.sql:4                     [report-with-fix]   medium
   §1 LN-1 — `from analytics_raw.invoices` (raw edge, no source()); fct_invoices and invoices_daily sit downstream and
   build before it on a fresh target. V559: sqlglot lists analytics_raw.invoices; edges.tsv has no parent.
   Fix: declare raw.invoices in _src.yml and use {{ source('raw','invoices') }}.
2. analytics/models/marts/fct_invoices.sql:1                       [report-flagged]    medium
   §3 LN-3 — read by apps/reporting/src/exports/finance-feed.ts:22 (partner file) with no exposure. Unproven link:
   whether finance-feed is scheduled. Settling: the scheduler file.

Trace (NODE=model.stg_invoices): downstream fct_invoices invoices_daily · upstream raw:analytics_raw.invoices
Verification: 9 candidates → 1 finding · 1 unconfirmed · 3 hardening · 4 refuted (self-verified; precedent § 24)

Class coverage:
  §1 hard-coded relation   checked (G 2 + 1 3 → 1 finding; 1 CTE refuted, precedent § 24)
  §2 layer direction       checked (G 2 + 2a 1 → hardening: small project, two layers)
  §3 undeclared readers    checked (3a 0 · 3b 4 marts → 1 unconfirmed) — PARTIAL: BI tool outside the repo
  §4 orphans               checked (G 2 · 4a 1 → hardening: the unused source feeds a reverse-ETL job, cited)
```

`[report-with-fix]`: V559 shows the missing edge, or the fact is two-ended (the raw relation and the absent edge; the reader file and the missing exposure), and the fix is local to the model or the YAML. `[report-flagged]`: one link is unproven (the reader's schedule, an alias, a cross-project build); the row names it. The coverage block lists **all four** §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)`; a trace prints `PARTIAL — <gap>` whenever a consumption layer was not read.

## 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:
- § 24 `information_schema`, `{{ this }}`, a macro-resolved relation or a CTE name.
- A small project with two layers (not LN-2); a source read by a non-dbt job, cited (not LN-4); an alias that changes the physical name (read before calling a mart unread).

## When to run

- Before changing or deleting a model, column or source (the trace); on any diff that adds a model, a `source()`, a raw relation or an exposure — `/audit-data-quality`'s lineage signal; when sizing a defect's blast radius under `/audit-data-model`.
- On every whole-repo `/audit-data-quality`, reporting all four §s.

## 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 relation, the edge list and the readers with their `file:line` — 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`.
- **Never present a partial trace as complete.** Hard-coded relations make the graph `PARTIAL — hard-coded edges`; an unread BI layer, export or reverse-ETL destination makes it `PARTIAL — consumption layer not traced`, with what access would settle it. Never imply zero readers because none were visible.
- **Halt before §1, §2 and §4 when model-graph is blocked** (exit 2): they print `blocked`, never `checked`. Exit 4 prints the dbt lane `N/A (no dbt project)`.
- **Never run `dbt` against a shared target and never emit lineage events** (`OPENLINEAGE_URL` stays unset). This skill writes nothing into the audited repo; `find <root> -newer "$OUT/graph.txt"` is empty at the end; a fix is `/data-fix`'s.
- **Do not grade a class another skill owns** (§ Boundaries). **Do not print "the lineage is complete"** without the coverage block.

## Related

- `/audit-data-quality` and `/audit-data-model` dispatch this skill; `contract-diff` §2/§4 and `/backfill-plan` call its trace for the reader list; `@warehouse-modeler` judges LN rows; `/data-fix` closes them.
- This pack: `incremental-load-scan/scripts/model-graph.py` (the graph), `warehouse-scan-audit` §4 (view-chain depth from the same edges), `pii-copy-scan` (where a PII column flows).
- business `report-scan` §2, code-quality `architecture-scan`, when those packs are installed.

## Sources

From the data-engineering research (2026-10-19): dbt-project-evaluator rules (`fct_hard_coded_references`, `fct_marts_or_intermediate_dependent_on_source`, `fct_source_fanout`, `fct_unused_sources`; v2.0.0, 2026-10-16); dbt `parse` / `ls` docs; sqlglot 30.23.0 table and column lineage (MIT); sqllineage 1.5.9; OpenLineage 1.54.0 (DE-A-63…67). Counts are from read-only runs of the block above. Paraphrased; no text reproduced beyond identifiers.

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.