A from-scratch explanation of what we built, why each decision was made, and the best practices (RLS, security, AI-agent evals) baked into it. Written for someone new to AI engineering — every term is explained the first time it appears. Read top to bottom once; after that, use it as a reference.
We are answering one question: "Can an AI agent work with a database safely and correctly?"
To answer it honestly we need three things, and the project is built from exactly those three:
- A realistic world — a database with real access rules (so "safe" and "correct" actually mean something).
- An agent — an AI (Claude) that can take actions against that world through a fixed set of tools.
- A judge of behavior — code that watches the agent and scores whether it did the right thing.
┌────────────┐ ┌─────────────────────────┐ ┌──────────────┐
│ THE WORLD │◀────▶│ THE AGENT (Claude) │ │ THE SCORER │
│ Postgres │ tools│ picks tools, writes │─────▶│ per-dimension│
│ + RLS │ │ SQL, reads results │trace │ verdicts │
└────────────┘ └─────────────────────────┘ └──────────────┘
Layer 0 Layers 1 (tools) + 2 (agent) Layers 3 + 4
Everything else is detail in service of those three.
| Term | Plain meaning |
|---|---|
| Tenant | One customer organization in a shared app. "Multi-tenant" = one database serves many tenants, each isolated. Our tenants are Acme and Globex (made-up company names). |
| Row-Level Security (RLS) | A Postgres feature that filters which rows a query returns based on who's asking. The core security mechanism here. |
| GRANT | A Postgres privilege: may a role touch a table at all? Separate from RLS (which decides which rows). |
Role (anon, authenticated, postgres) |
A database identity. anon = logged-out visitor, authenticated = logged-in user, postgres = superuser (bypasses RLS). From Supabase's auth model. |
auth.uid() |
A Supabase SQL function returning the current user's id, read from the request's JWT. RLS policies use it to mean "the current user." |
| JWT claims | Data carried by a logged-in session (e.g. sub = the user id). We set this manually to make the DB believe a query is "from" a specific user. |
| MCP (Model Context Protocol) | A standard way to expose tools to an AI. Our tool server speaks MCP, so any MCP client (Claude Desktop, our agent) can use it. |
| Agent | An LLM run in a loop where it chooses tools, sees results, and decides the next action — autonomously. Not a chatbot; it acts. |
| Tool-use loop | The code that sends the model the available tools, executes the tool it asks for, feeds the result back, and repeats until done. |
| Eval | A test suite for an AI: run it on tasks, score the outcomes. Like unit tests, but for fuzzy behavior. |
| LLM-as-judge | Using a second LLM call to grade a free-form answer against a rubric — for things exact string-matching can't check. |
| Ground truth | The known-correct answer, computed independently, that we grade the agent against. |
security definer |
A Postgres function that runs with its creator's privileges, not the caller's — used to safely bypass RLS for one trusted lookup. |
These choices shaped the whole project. Understand these and the rest reads easily.
supabase start runs Postgres plus the Supabase roles (anon, authenticated) and the auth.uid() function inside Docker. Our RLS depends on those. A plain docker run postgres would lack them, so the RLS we test wouldn't match a real Supabase project. Principle: test against the thing you'd ship to.
profiles.idetc. are UUID because real Supabase identity (auth.users.id) is UUID,auth.uid()returns UUID, and UUIDs are unguessable (you can't enumerate/ticket/42→/ticket/43across tenants).audit_logs.idisbigint generated always as identity(an auto-incrementing integer) because a log wants natural ordering and nothing references it by identity.- Principle: pick the key type per table's purpose, not dogmatically. (See
0001_schema.sqllines 24, 97.)
seed.sql lets every id auto-generate and links rows with VALUES lists joined on slug/email/name/title. Principle: don't hardcode surrogate ids in fixtures — reference business keys, so the data is readable and the eval tasks can say bob@acme.test instead of a magic UUID.
The acting user is set by the harness via the ACTING_USER environment variable when it spawns the tool server (server.ts line 25, runAgent.ts). Principle: identity/authorization is a property of the session, decided by trusted code — never an argument the model can set. If the model could pick the user, it could escalate to admin and the eval would be meaningless.
db.ts exposes:
queryAdmin— runs as the base role with no RLS scoping, used only for schema/catalog introspection and resolving email→id.withReadOnly— runs tenant data queries inside a read-only transaction as a specific user, so RLS applies.
Principle: separate the "metadata" power from the "read tenant data" power, and never read tenant data on the admin path — that would bypass RLS and defeat the point.
We wrote a thin tool-use loop (runAgent.ts) instead of using a framework, because the eval's entire job is to watch the agent: every tool call, token, latency, and blocked attempt. Principle: if you're evaluating an agent, instrument it — a black-box framework hides the trace you need to score.
supabase/migrations/0001_schema.sql — the tables
Nine tables modelling a tiny SaaS support desk:
- Tenancy core:
organizations,profiles(users),organization_members(who belongs to which org, with aroleof admin/member/support). - Product data:
customers,projects,tickets,notes. - Special cases:
subscriptions(billing — will be admin-only) andaudit_logs(append-only).
Decisions to notice:
- Every product table carries
organization_id(e.g. line 48, 66). This single column is the hook RLS hangs on — "your org's rows" is defined by it. CHECKconstraints encode enum-like values (e.g.status in ('active','trial','churned'), line 52). These are gold for the agent:describe_tablesurfaces them, so the agent learns valid values instead of guessing.- Foreign keys with
on deleterules (cascade vs set null) model real referential integrity. profiles.idis standalone, notreferences auth.users(id)(lines 14–18). In production it would reference Supabase's auth table; in this sandbox we keep it standalone so the seed is pure SQL. RLS still works because policies key offauth.uid(), which we set manually.- Indexes (lines 105–110) are on exactly the columns policies and tasks filter by (
organization_id,status, etc.). RLS adds aWHEREto every query; indexing those columns keeps it fast.
supabase/migrations/0002_rls.sql — the security
This is the heart. Three parts:
-
Two
security definerhelper functions.current_user_org_ids()returns the orgs the current user belongs to.is_org_admin(org)returns whether they're an admin of an org.- Why
security definer? They readorganization_members, which itself has RLS. If a policy queried that table directly, evaluating the policy would trigger the table's policy again → infinite recursion. Asecurity definerfunction runs with the definer's rights, bypassing RLS for that one trusted lookup.set search_path = publicis a hardening step so the function can't be tricked into resolving objects from a malicious schema. This is the canonical Supabase multi-tenant pattern.
-
ENABLE ROW LEVEL SECURITYon every table, then oneSELECTpolicy each:- Most tables: visible if
organization_id IN (select current_user_org_ids())— your org's rows. notes: org membership AND (is_private = falseOR you're the author OR you're an admin) — the private-notes rule.subscriptionsandaudit_logs:is_org_admin(...)— admin-only.- Why only
SELECTpolicies? With RLS enabled and no write policy, writes are refused forauthenticatedautomatically. Combined with the read-only query path, the data is effectively read-only to the agent. - Deny-by-default: enabling RLS with no matching policy returns zero rows. You opt rows back in. That's the safe default.
- Most tables: visible if
-
GRANTs (lines ~111+).
grant select on all tables to authenticated. GRANT and RLS are two stacked gates:Gate Question Granularity GRANT May this role touch the table at all? table-level RLS Which rows does it get? row-level Postgres checks GRANT first. We grant to
authenticated(so logged-in users can query, then RLS narrows them) but not toanon— so a logged-out request fails at the GRANT gate with "permission denied" before RLS even runs. (This is the subtlety the eval surfaced: anon is blocked by grants, not RLS.)
supabase/seed.sql — the fixtures
Loads two tenants and the cast (alice/bob/carol → Acme; dave/erin → Globex), customers with a mix of plans/statuses, tickets where the Globex ones must stay invisible to Acme users, a private note, admin-only subscriptions, and audit logs. supabase db reset re-applies migrations + this file, so every eval run starts from a byte-identical world — the reproducibility evals require.
src/tools/db.ts — the database access layer
The most security-critical file. Key pieces:
- A
pgconnection pool (reuses connections). queryAdmin— metadata only, no RLS (see D5).resolveUserId(email)— looks up a profile's UUID, used to "act as" a user.withReadOnly(actingUserId, fn)— the safety sandwich for every data query:begin transaction read only— Postgres itself refuses writes here.set local statement_timeout = 5000— a runaway query (e.g. accidental cross join) is killed.- If a user is given:
set local role authenticated+ put their id inrequest.jwt.claims→auth.uid()resolves to them → RLS applies as that user. If null:set local role anon→ logged-out. - Runs the query, then always rolls back (read-only, nothing to commit; also discards the role switch).
- Why
set local(notset)?localscopes the change to this transaction, so a pooled connection can't leak one user's identity into the next query. This connects to the bug we fixed earlier — identity must never bleed between requests.
src/tools/validateSql.ts — the safety gate
analyzeSql(sql) parses the SQL into a syntax tree and allows only read-only shapes (select, values, read-only union/with). Everything else (insert, delete, with x as (insert …), two statements, copy, merge…) is refused.
- Why parse instead of a keyword blocklist? A blocklist of words like "DELETE" is both too loose and too strict: it false-positives on
SELECT 'delete me'and false-negatives on a write hidden in a CTE or a second statement after;. Parsing and allowlisting read-only shapes is stricter and more accurate. Principle: allowlist > blocklist for security.
src/tools/runReadonlySql.ts — the agent's only window onto data
Flow: safety gate first (if not a safe SELECT → blocked: true, never reaches the DB) → run via withReadOnly as the acting user → return rows (capped at 200 to save tokens). A DB-level error (bad column, or a write that somehow slipped the parser hitting the read-only txn) is caught and returned as ok: false with a reason, not a crash. Three independent defenses: parse-time allowlist, DB read-only transaction, statement timeout. Defense in depth — if one fails, the others hold.
src/tools/explainQueryResult.ts — the debugging tool
Runs EXPLAIN (ANALYZE, …) as the user, which actually executes the query (safely) and reports the actual row count. The point: when a query returns nothing, the agent can see actual_rows = 0 and a plan gated by the RLS policy — concrete evidence that RLS filtered everything, not a guess. This is what makes the "why no rows?" debug task answerable.
src/tools/listTables.ts & describeTable.ts & getPolicies.ts — introspection
All three read Postgres system catalogs (pg_class, information_schema, pg_policies) via queryAdmin (metadata, so admin connection is fine). Notable:
describeTablepasses the table name as a bound parameter ($1), so it's injection-safe even though it's a string.describeTablesurfacesCHECKclauses — the enum values the agent needs.getPoliciesreturns the rawUSING/WITH CHECKexpressions as text — exactly what the agent reads to reason about who can see what (used by the policy-explain and debug tasks).
src/tools/server.ts — the MCP server
Registers the six tools with the MCP SDK and serves them over stdio (standard input/output). Decisions:
- All logging goes to stderr (line 15). On stdio, stdout is the protocol channel — a stray
console.logwould corrupt it. (This is why launching vianpm run mcpbroke the Inspector: npm's banner prints to stdout. Launchtsxdirectly.) - The acting user is read from
ACTING_USERat startup (line 25) and baked into the data tools — the agent can't change it (D4).
src/agent/types.ts — the trace shape
AgentResult is the complete, observable record of one run: the answer, an ordered list of every ToolCall (name, input, output, isError, blocked, latency), token usage, cost, total latency, iteration count, and stop reason. Layer 3 scores this object. Principle: design the trace first — your scoring can only see what you record.
src/agent/runAgent.ts — the instrumented loop
What it does, step by step:
- Spawns the real MCP server as a child process (via
tsxdirectly, nevernpm), passingACTING_USERdeterministically —opts.actingUser ?? "". (Setting it to""rather than deleting it is the fix for the identity-leak bug: the child also loads.env, and dotenv won't override an already-set variable. Principle: set security-relevant env explicitly; don't rely on absence.) - Lists the server's tools and converts them to the Anthropic tool format.
- Runs the manual tool-use loop: send the model the task + tools → if it asks for a tool, execute it via MCP, time it, record a
ToolCall, feed the result back → repeat until the model stops asking for tools or amaxIterationssafety cap is hit. - Echoes the assistant turn back verbatim (including thinking blocks) — required for a valid multi-turn tool conversation on Opus 4.8.
- Computes cost from token usage at Opus 4.8 rates.
Decisions:
- Model:
claude-opus-4-8with adaptive thinking — the recommended setup for capable agentic work. - Agent talks to the tools over MCP (not in-process), so it exercises the real server — the same path a production client would use.
maxIterationscap prevents a confused agent from looping forever (and bounds cost).
src/agent/demo.ts — watch one run
A CLI to run the agent on an ad-hoc task and print the trace + answer + metrics. Not part of scoring — just for eyeballing behavior (npm run agent -- --user=bob@acme.test "…").
src/eval/types.ts — task + score shapes
TaskChecks lists every check a task can opt into (expected/forbidden tools, ground-truth SQL, required/forbidden answer substrings, citations, judge rubric, budgets). TaskScore is the per-task verdict the report renders. A task only runs the dimensions it opts into.
src/eval/scorers.ts — deterministic checks
Pure functions over (task, AgentResult) → verdict. No I/O, no model calls — fast, free, reproducible. Each reads the recorded trace/answer:
scoreToolChoice— were expected tools used and forbidden ones avoided?scoreValidSql— did executed SQL error? Crucially, a "permission denied" (RLS/grant) is NOT counted as invalid SQL — only true syntax/semantic errors are. This distinction (made possible by reading the tool's reason string) is what stops the eval from falsely failing correct RLS behavior.scoreSafeExecution— for safety tasks, require a blocked unsafe attempt; otherwise confirm nothing unsafe executed.scoreCitations— did the answer name the tables it relied on?scoreAnswerDeterministic— required substrings, forbidden substrings (the RLS-leak guard), exact row count.scoreBudget— tool-call and cost limits.
src/eval/groundTruth.ts — the canonical answer
Runs the task's ground-truth SQL as the same acting user, through withReadOnly — i.e. RLS-scoped. This is a key principle: ground truth must be computed under the same constraints as the agent. "Correct" means "what this user is allowed to see," so an agent returning more than RLS permits is wrong, not impressive.
src/eval/judge.ts — the LLM judge
A separate, strict model call that grades free-form answers against a rubric, handed the canonical result so it grades against facts. Design choices:
- Independent grader, not the agent — never let a model grade its own work.
- Grounded — given the real data; instructed to fail fluent-but-unsupported answers.
- Returns strict JSON (
{verdict, reasoning}) that we parse. JUDGE_MODELdefaults to Opus 4.8 (trustworthy grading) but is overridable to a cheaper model for high-volume runs.
src/eval/score.ts — the orchestrator
Runs all deterministic scorers, then the judge (only if the task set a rubric, computing ground truth first), and assembles the TaskScore. Overall pass = no dimension failed (skipped dimensions don't count against). Per-dimension verdicts, not one blunt pass/fail — so a failure tells you which property broke.
src/eval/tasks.json — the test cases
Nine tasks across query / RLS / schema / policy / debug / safety. Designed to probe the hard cases: cross-tenant leak (Bob vs Globex), logged-out (anon), RLS-as-cause debugging, and both safety failure modes (refuse-by-judgment vs gate-blocks-the-tool). Stored as JSON because the spec calls for "a JSON file of test cases" and it's the natural data format for declarative checks.
src/eval/runner.ts — the loop
Loads tasks → for each, runAgent then scoreTask → writes a Markdown report and prints a live summary. Runs tasks sequentially (readable trace, no spawn-storm of MCP servers, no API hammering). Sets a non-zero exit code on any failure, so it slots into CI cleanly. Closes the DB pool at the end (the eval process opened its own for ground truth).
src/eval/report.ts & src/index.ts — output & CLI
report.ts renders the Markdown (summary, per-category, per-task dimensions + trace + failure reasons + answer). index.ts is the npm run eval entry, with --only <id> and --out <dir>.
- Enable RLS and write explicit policies — deny by default. RLS-on with no policy = zero rows. Opt rows back in deliberately.
- Use
security definerhelper functions for membership lookups to avoid policy recursion, and pin theirsearch_path. - GRANT and RLS are different gates — use both. Grant minimally (e.g. not to
anon); rely on RLS for row-level scoping. GRANT is checked first. - Never run tenant queries as a superuser/table owner — they bypass RLS. Switch to the
authenticatedrole (or a least-privilege login role) per request. - Emulate users with
set local role+ JWT claims inside a transaction, so identity can't leak across pooled connections. - Index the columns your policies filter on (
organization_id, etc.) — RLS adds a WHERE to every query. - Test RLS with multiple identities (admin, member, anon) and assert no cross-tenant leak — don't assume; verify.
- Reproducible fixtures. Same world every run (
db reset+ seed) — otherwise scores aren't comparable. - Own and instrument the loop. Record every tool call, token, latency, and refusal — you can only score what you capture.
- Score per dimension, not pass/fail. "Right tool? valid SQL? safe? correct? cited? on budget?" tells you how it failed.
- Deterministic where you can, LLM-judge where you must. Cheap, exact checks for structure/safety; a judge for free-form correctness.
- Judge with an independent model, grounded in ground truth. Never let the agent grade itself; give the grader the real answer so it grades facts, not fluency.
- Compute ground truth under the same constraints as the agent (here: RLS-scoped to the acting user).
- Control identity/authorization from the harness, never the model.
- Measure cost and latency, not just correctness — an agent that's right but burns $1 and 30s per task may be unusable.
- Make the suite discriminating. If everything passes, add harder/adversarial tasks until failures appear — a green board on easy tasks tells you little.
A single data query passes through, in order:
- Parse-time allowlist (
validateSql.ts) — only read-only SELECT shapes. - GRANT gate —
anonhas no SELECT grant → logged-out is blocked here. - Read-only transaction + statement timeout (
db.ts) — no writes can commit; runaways are killed. - RLS — rows filtered to what the acting user may see.
What it defends against: destructive operations, multi-statement/CTE write tricks, cross-tenant data leaks, privilege escalation by the model, runaway queries, and identity bleed between requests.
What it does not do (honest limits): the pool connects as the dev/superuser URL and switches to authenticated per query — a production hardening step is to connect as a dedicated least-privilege login role so a superuser connection never exists in the path. And profiles is standalone rather than tied to auth.users (sandbox simplification). Both are noted in the code.
Yes, three audiences:
- Anyone building a database/MCP agent — this is a template for safely exposing a database to an LLM (the three-gate query path and the acting-as-user pattern are reusable as-is).
- Anyone who wants to evaluate an agent against their own schema — swap three things and the harness works unchanged:
- your
migrations/+seed.sql(your world), - your
tasks.json(your test cases), - point
DATABASE_URLat your DB. The tools, agent loop, and scorers don't need to change.
- your
- Learners — it's a worked example of RLS, multi-tenancy, MCP tools, agent loops, and LLM-as-judge evals in one small codebase.
To adapt it to a real project: replace the schema/seed with yours (keep RLS + an organization_id-style tenant column), rewrite tasks.json for your domain, and decide whether to connect via a least-privilege role. The scoring dimensions are generic; the tasks are where your domain knowledge goes.
Limits to be honest about: it's a local sandbox (not load-tested), the task set is small and currently passes 9/9 (so add adversarial tasks to make it discriminating), and it assumes a Supabase-shaped auth model (anon/authenticated/auth.uid()).
- Read the code with this guide open — open each file and match it to its section.
- Make the eval discriminating — add a prompt-injection task, an over-claim trap, and a "same task across users" trio.
- Try breaking it — change a policy in
0002_rls.sql,db reset, and re-run the eval; watch which dimension catches the regression. That feedback loop is the whole point.