The fetch registry
The read-only data layer is a validated allow-list, not a query builder: declared tables, declared columns, one bound key, SELECT only, and no model-derived string within reach of SQL.
An agentic system that reads a data warehouse is one prompt injection away from reading the wrong thing. The obvious mitigations (a read-only account, a SQL linter, a careful prompt) are fine and insufficient on their own. The platform’s answer is that the model never constructs a query at all, and neither does the calling code. The only way to read the warehouse is through a registry.
The registry
A YAML file, authored from the business’s own field-mapping sheet and loaded into validated Pydantic models. Per field, it declares where the authoritative value lives and how to fetch it. It carries no comparison logic.
# one entry per field — around thirty across identity, account, balance, payment, timeline, document linkage
- field_id: current_balance
section: balance
mode: direct # direct | custom_sql
cardinality: one # one | many
match: eq # eq | like
table: <declared table>
columns: [<declared column>] # never *
key_column: <declared key>
key_source: account_id # an input key, or a resolvable hop
Three validators enforce the invariants at load time, so an invalid registry fails the process at startup, not at query time:
- Identifiers (table, columns, key column) must match a conservative SQL-identifier pattern. No wildcards, no expressions, no interpolation.
- Custom SQL entries are forced
SELECT-only and must contain exactly one?bind. There is one such entry across both products: an address-history join that spans four tables. It exists because the join was genuinely not expressible as a direct entry, and it is reviewed like code. - Key sources must be either a declared input key or a declared one-hop resolution: a table, a key column, and a value column that derive a non-account key from the account ID.
The engine
fetch_fields(account_id, field_ids):
- Groups direct entries by
(table, key_column)and emits one parameterisedSELECTper group. An account costs one query per table it touches, not one per field. - Runs each custom-SQL entry with its single bound key.
- Resolves non-account keys through the declared hops first, caching the resolved key for the call.
- Splits the rows back by each field’s declared cardinality, so a
manyfield returns a list and aonefield returns a scalar and an error if it doesn’t.
Output is {field_id: FieldValue}, carrying the database reference as a Citation so a comparison can point back at the row it read.
Defence in depth
Read-only is enforced twice. In code, by the allow-list. At the warehouse, by a SELECT-only Managed Identity principal used by nothing but the fetch engine, so even a bug in the allow-list can’t write. A short-TTL cache sits in front, because the same account is hit by several checks in parallel and by several letters from the same customer.
Why not just write the queries
Because the queries change, and the people who know how they should change don’t ship code. Which column holds the authoritative balance, which table the identity fields live in, how a debtor key is derived from an account. These are facts the business owns, and they drift. In the registry they are a versioned config diff, validated on load, with the SQL surface fixed. Two questions this design cannot answer, whether two differently-named keys are the same value, and what happens when one account maps to several debtors: are exactly the questions that had to go to the data owners rather than be guessed, and the registry made them visible as ⚠ confirm annotations instead of silent assumptions in a query.