Essay

If SQL makes the decision, why keep the graph?

A graph is for questions shaped like a path, where the answer is an explanation. A semantic layer is for questions whose answer is a number a policy depends on.

A bank's facts about one borrower rarely sit in one place. In Brightwater, the fictional lender behind this series, ownership lives in a registry, loans and payments live in a credit system, and cash flow lives in a ledger. They share a business identifier and nothing else: no foreign key crosses from one system to another. That is realistic, and it shapes everything below.

Brightwater runs a Neo4j graph and a semantic layer side by side, and at first they look like they compete. Both can show that an applicant shares an owner with another business whose loan is overdue. My view after building it is that they answer different kinds of question. A graph is for questions shaped like a path, where the answer is an explanation. A semantic layer is for questions whose answer is a number a policy depends on. The trouble starts when one tries to do the other's job.

The code is at github.com/tankcdr/brightwater-agent. Chapter 1 builds the graph (git switch -c my-ch1 ch1), and the examples below come from it and the chapters around it.

Some facts are paths

Northpine Freight applied for a loan, and its own records are clean. Its debt service coverage ratio (DSCR), the cash it has for debt payments divided by the payments due, is 1.51 against an approve line of 1.25. The reason it goes to review sits behind a shared owner. Priya Lindqvist owns 40% of Northpine and 35% of Ridgeback Tile Co, and Ridgeback's term loan is 80 days past due. Brightwater's policy holds back automatic approval when a business that one of the applicant's owners holds 25% or more of has a loan 60 or more days late.

Finding it means joining ownership in the registry to loans and payments in the credit system. Written in Structured Query Language (SQL), that is a self-join on ownership with a correlated subquery on payments, and few people can read it twice. In a graph it is one traversal, and what comes back is the reason itself: Northpine, through Priya, to Ridgeback, to a loan 80 days late. A credit officer can read that row. So can an examiner.

Harrow & Finch Outfitters is the same shape with money instead of lateness. On its own its exposure is $850,000, including the loan it is applying for, against a $1,500,000 cap. Its owners also control three other businesses, and the group's exposure, again including the request, is $1,722,000. The policy defines the group by control, more than 50%, so the real question is which businesses each owner controls. That is also a walk along ownership edges.

This is what a graph gives you that tables make hard: entities connected across systems that were never designed to connect, and an answer you can show someone. The deeper the chain, the bigger the advantage. Real ownership structures nest (a holding company owning a limited liability company that owns the borrower), and a path of unknown depth is one pattern in a graph query and a recursive common table expression in SQL. Brightwater does not model that yet: people own businesses directly, and no company owns another. The argument for depth is one I expect to test, not one this repo proves.

A graph the agent trusts has to refuse bad facts

Connections are only useful if they are true, and an agent will reason over whatever its memory holds. If the graph says someone owns 30% of a business that does not exist, the agent will cite it.

The source systems will not stop that. Brightwater's source tables have no constraints that reject a stake in a business no system knows, or a second owner that pushes one business to 135% owned. No foreign key crosses the schemas, and a column constraint cannot sum rows. Brightwater does the check before import: one schema describes the entities, and every import batch is validated against it before anything is written. The broken batch was refused whole, with both bad rows named, and the graph never saw it. That checks consistency, not truth. A stake recorded as 20% instead of 35% passes validation but changes whether the overdue loan triggers P5.

Building the graph prompted a useful discipline: define the entities and relationships before importing them. That validation is useful even without a graph, and in Brightwater it finishes before any Neo4j connection exists. It is slow, about 45 seconds over the full sample book in the recorded run, so it belongs on an import job and not on a request.

Numbers belong somewhere else

The problem isn't computing a number in a graph. It's computing the same policy-critical number in two places, with nothing checking that they agree. DSCR, days past due and group exposure are definitions, and definitions need governance: one version, checked against the raw data, changed in one place, with a record of when it changed.

Brightwater keeps those in a semantic layer: one definitions file, compiled and served through Cube. Change a definition there and you see the blast radius before shipping it. The chapter's recorded DSCR change, dropping non-operating inflow, moves 20 of 1,206 applications, and Larkspur Bindery falls from an approve at 1.33 to a review at 1.18.

If the graph computed DSCR as well, that change would land in one place and not the other, and two parts of the same system would give different answers. Brightwater is not fully clean here. Its graph query computes days past due to build the explanation, mirroring the governed definition, and nothing checks that the two stay in step. A change to the definition could leave the explanation quietly disagreeing with the decision.

Where the graph drops out

Here is the uncomfortable result. Once the procedure is compiled, the decision does not touch the graph. The semantic layer already serves the facilities one hop out, with the owner's stake and days past due, and that is what the procedure reads. Northpine goes to review on governed numbers alone.

The graph stays for the work that is not a fixed procedure. The free-form agent records a reasoning trace there for every run, so you can query which tools it called and in what order. With the embedding service configured, it can also pull relevant memory into its prompt before each model call. An analyst investigating a borrower can ask who else connects to them without knowing the question in advance. Those uses grow with how open-ended the work is, and they shrink as the work gets compiled.

I have not shown that the memory makes the free-form agent better, and I have not run it with and without the graph. Until that runs, the claim I can make is narrower: the graph makes connections readable, validation rejects inconsistent records before import, and the decision gets its numbers elsewhere.

What I'd do differently

  • Test that the graph and semantic layer agree on the related loans that meet P5's thresholds, accounting for their different query shapes, so the explanation cannot drift from the decision.
  • Run the free-form agent with and without graph memory, with embeddings on, and publish the difference in outcomes and cost.
  • Test whether nested ownership investigations justify keeping the graph, while leaving exposure calculations in the governed semantic layer.

The idea of a graph as agent memory comes from the Neo4j talk this series credits and from Neo4j Labs' neo4j-agent-memory.