By Alex Suarez · written for the Graph Hacks: Context for AI Agents hackathon (WeMakeDevs × FalkorDB)
Live demo: https://lineagegate.fly.dev · Code: https://github.com/nightmoose/lineagegate
I've been a data engineer since 1997. The tools have changed many times over: cron and shell scripts, then SSIS, then ETL suites, then Hadoop, then the cloud warehouse with dbt on top. One conversation hasn't changed in all that time:
"Hey, we're changing
amountto cents in the orders feed. That's fine, right?"
Nobody can answer that with confidence. The column feeds a staging model, which feeds three intermediate models, which feed a fact table, a reconciliation mart, a customer lifetime value model and five dashboards. Two of those dashboards belong to people who've never heard of the orders feed. Somewhere in there is a data contract that says "USD dollars, not cents", written after the last time this exact thing happened.
The second conversation is the 7 a.m. one: "The customer mart job failed. Why, and who do I page?" The error message names a failing test. The cause sits four hops upstream, in a CRM backfill. We've seen it before, but the write-up is in someone's old ticket and nobody remembers it.
Both questions are about relationships, not rows. That's why I built LineageGate on a graph.
Why a graph, specifically
You can answer "what's downstream of this column" with recursive SQL over an edge table. I've done it. It's painful to write, worse to maintain, and the answer comes back as a flat list of names. The flat list is the real problem: when I tell a finance lead their dashboard will break, the first thing they ask is "how?". I need the chain:
stg_orders.amount → int_order_revenue.gross_revenue → fct_orders.order_amount → fct_revenue_daily.revenue → mart_finance_revenue.revenue_usd → finance.revenue_daily
That chain is a path, and a graph database returns paths natively. Impact analysis is a variable-length traversal. Ownership is a multi-hop walk. "Have we seen this before?" is graph distance between today's failure and old incidents. Vector similarity can't give you a path, and a path is what earns trust.
The data model
LineageGate runs on a fully synthetic "Acme Analytics" warehouse: 111 nodes, 248 edges, 11 labels and 12 relationship types. It's small enough to read and big enough to be realistic. All lineage edges point upstream ("depends on"), so impact analysis means walking incoming edges.
flowchart RL
Job>Job] -->|READS_FROM| Source([Source])
Job -->|WRITES_TO| Table[Table]
Job -->|REFRESHES| Model[Model]
Table -->|HAS_COLUMN| Column[Column]
Model -->|HAS_COLUMN| Column
Model -->|READS_FROM| Table
Model -->|DEPENDS_ON| Model
Column -->|DEPENDS_ON| Column
Dashboard[[Dashboard]] -->|DISPLAYS| Model
Column -->|GOVERNED_BY| Contract{{Contract}}
Test[/Test/] -->|GOVERNED_BY| Contract
Test -->|READS_FROM| Column
Incident((Incident)) -->|TRIGGERED_BY| Job
Incident -->|VIOLATED| Contract
Incident -->|RELATED_TO| Incident
Model -->|OWNED_BY| Team[Team]
Person[Person] -->|MEMBER_OF| Team
Two design choices mattered most:
-
Column-level lineage is first-class.
Column -[:DEPENDS_ON]-> Columnedges let impact be precise. Changingstg_orders.amountdoesn't flag the support dashboard just because it shares a model withstg_orders. - Incidents are nodes, not documents. An incident links to the job that surfaced it, the contract it violated, and related incidents. Memory is part of the graph, so it can be queried like the graph.
Three queries doing the real work
Every tool the agent can call is a parameterised Cypher template. The model never writes Cypher itself. Here is the core of column impact, the path from a column to every dashboard it reaches:
MATCH (c:Column {fqn: $fqn})
MATCH p = (c)<-[:DEPENDS_ON*0..8]-(:Column)<-[:HAS_COLUMN]-(:Model)<-[:DISPLAYS]-(d:Dashboard)
RETURN d.id, d.name, length(p) AS hops,
[n IN nodes(p) | {id: n.id, label: labels(n)[0], name: coalesce(n.fqn, n.name)}] AS pnodes,
[r IN relationships(p) | {type: type(r), src: startNode(r).id, dst: endNode(r).id}] AS prels
ORDER BY hops
The two list comprehensions at the end matter more than they look. They project every path into plain node and edge maps on the server, so both the LLM and the UI receive the evidence in a form they can quote or draw.
Failed-job triage is one pattern from the failed job up to every ingestion job that feeds it:
MATCH p = (:Job {id: $id})-[:REFRESHES]->(:Model)-[:DEPENDS_ON*0..8]->(:Model)
-[:READS_FROM]->(:Table)<-[:WRITES_TO]-(uj:Job)
RETURN uj.id, uj.status, pnodes, prels // same path projection as above
For "which model would hurt most if it broke", I didn't write any graph math. I used FalkorDB's built-in algorithms:
CALL algo.pageRank('Model', 'DEPENDS_ON') YIELD node, score
RETURN node.id, score ORDER BY score DESC LIMIT 5
PageRank and algo.betweenness both put dim_customer at the top. That's the hub I planted in the seed data, and it's the model that would hurt most in real life. LineageGate then attaches the dashboard paths that make it critical, so even an algorithm result comes with evidence.
How Grok picks the graph tools
The agent loop is deliberately small: one Python module, no agent framework. It talks to xAI's OpenAI-compatible API and hands Grok seven tools:
impact_from_columnowners_for_node-
paths_explain(k shortest paths viaalgo.SPpaths) triage_failed_jobsimilar_incidentsrecord_incidentcritical_assets
Each tool has a one-paragraph description. The system prompt sets a few hard rules:
- Always call a tool before answering.
- Quote at least one returned path word for word.
- If a tool finds nothing, say the graph has no evidence and stop.
I never route by keyword. Ask "a customer job failed last night, have we seen it before, who should I page?" and Grok calls triage_failed_job with no arguments. That tool defaults to the most recent failure and already includes similar incidents. Ask about cents and dollars and it calls impact_from_column(table="stg_orders", column="amount").
Speed matters on camera. grok-4.3 gave great answers but took 5–26 seconds end to end. Switching the default to grok-4.20-0309-non-reasoning cut that to roughly 3.6–6.4 seconds per question in my dry run against the live site. With no API key, a deterministic offline router picks the tool from the question instead, so the demo never depends on the network.
Memory that survives the session
Hackathon Track 02 is about agent memory, and I wanted memory to be more than a chat transcript. record_incident MERGEs a new Incident node and links it into the lineage:
-
TRIGGERED_BYto the job -
VIOLATEDto each broken contract -
RELATED_TOto its two most similar past incidents
similar_incidents then ranks incidents by graph distance from whatever you're asking about, combined with keyword overlap. Ask about stg_orders.amount in a brand-new session and the agent finds INC-0802 (the August cents-versus-dollars bug) through this path:
(Column stg_orders.amount) -[:GOVERNED_BY]-> (Contract orders_amount) <-[:VIOLATED]- (Incident INC-0802)
Nobody tagged that incident with the column name. The graph connects them through the contract. That's the kind of recall I've wanted in every incident review I've ever sat through.
Explainable by default, and honest when it can't answer
The UI is one HTML file served by FastAPI. Chat is on the left. The evidence panel on the right lists every tool the agent called and every path in the result, drawn left-to-right with vis-network. Click a path and it lights up. A second tab renders the live schema straight from FalkorDB (db.labels(), db.relationshipTypes() and a count of each label-type-label triple).
I spent as much time on the "no" cases as on the "yes" cases:
- Misspelled column: the agent suggests close matches taken from the graph itself.
- Ambiguous name: it lists the candidates instead of picking one.
- Empty graph: it tells you to seed it.
-
Unknown column: ask about
stg_orders.amount_eurand it says "the graph has no evidence" and offersstg_orders.amount. It doesn't invent a plausible-sounding blast radius.
An opt-in test sends an amount_eur question to the live model and asserts it abstains and prints no fabricated path.
Running it
It's one Fly.io machine running FalkorDB and the app together. On boot, it seeds the graph if it's empty, so a restart heals itself. Locally:
docker compose up -d
pip install -e '.[llm,api,dev]'
lineagegate seed && lineagegate serve
What I'd do next
-
Real ingestion: load lineage from dbt
manifest.jsonand warehouse query logs instead of synthetic CSVs. The graph model wouldn't change. -
Pre-merge checks: run
impact_from_columnagainst a pull request's changed columns and post the paths as a review comment before the change ships. -
Persistent memory: add a volume and let the triage flow propose
record_incidentwhen an on-call engineer confirms a root cause. - Weighted paths: score edges by query volume, so "critical" reflects actual usage, not just structure.
Thirty years in, my data quality questions are still the same ones. What's new is that an agent can answer them, as long as the answer is a path through a graph it can show you.
Built in four days with FalkorDB, Python, FastAPI and xAI Grok. AI coding assistants helped write the code; see DISCLOSURE.md in the repo.
Demo video: https://youtu.be/ARw7zSb5Jdo


Top comments (0)