An agent we shipped last quarter returned a row from the wrong tenant. The agent did exactly what the prompt asked. The database did exactly what the agent asked. The customer support call was still ours to make.
Here is what was actually happening. The agent ran a SQL query through the Supabase service-role key, which bypasses row-level security entirely. The agent's prompt said "summarize this user's recent orders." The agent picked a user, ran the query, returned the answer. It happened to be the right tenant in development. It happened to be the wrong tenant the day we shipped.
Our last engineering essay described two jurisdictions of a Python program for TypeScript developers. Pydantic validates data at runtime when it arrives. TypedDict describes the shape of data for the IDE and the type checker. The essay ended with the code-generation step that bridges them. It did not name the third jurisdiction. The third jurisdiction is the database, and every agentic workflow crosses it on every node.
Pydantic does not know which tenant the agent is supposed to serve. TypedDict does not know either. The static type checker cannot see across the database boundary because the boundary is not made of types. It is made of rows that belong to different tenants, and the only thing that can tell them apart at the moment a query runs is the database itself.
I want to tell you what the third jurisdiction is, why teams default to bypassing it, the pattern that actually closes the gap on a LangGraph + Supabase integration, and what to ask of your existing agentic codebase this week.
The day an agent returned the wrong tenant's data
The agentic feature we were shipping read order history from a multi-tenant Supabase database. The Postgres schema was healthy. The tenants had been separated since day one with a tenant_id column on every table. Application-level code threaded the tenant_id through every query and filtered explicitly. The team had been doing this for two years. It worked.
LangGraph arrived in the middle of that two-year track record. The agent's job was to summarize recent orders for the current support ticket. Two nodes, three tools, simple Pydantic state. The team wired the agent to Supabase with the service-role key because that is how the Supabase Python client examples are written when you want unauthenticated access. The agent's queries went out under the service role. The service role bypasses RLS. The application-level tenant_id filter still ran for every query the agent issued because the agent's prompt included the tenant_id in context. Most of the time.
The day we shipped, an agent invocation got constructed from a stale context object. The tenant_id in the prompt was for tenant A. The user_id the agent picked from the data returned by an earlier node belonged to tenant B. The agent issued a SELECT for that user's orders. The service role returned them. None of the rows belonged to the customer making the request.
The bug was not in the prompt. The bug was not in the Pydantic state schema, which validated cleanly the whole time. The bug was that the agent had access to every tenant's data and the wrong filter for the wrong invocation. Nothing in the program's structure stopped that combination from running.
The three jurisdictions
Every multi-tenant agentic workflow enforces three different invariants in three different places, at three different times.
Pydantic lives in runtime memory. It runs the moment data arrives at a node. It enforces shape: this field is a string, this one is an integer, this list contains at least one element. If the data does not match, Pydantic raises a ValidationError and the agent does not get to act on it.
TypedDict lives in static analysis. It runs when the type checker runs, which is during development or in CI. It enforces nothing at runtime; it tells the IDE and the developer what shape the data is supposed to have so refactors do not silently break consumers. A TypedDict definition is a contract for tools, not for the program.
Row-level security lives in the database. It runs on every query. It enforces visibility: this row belongs to this tenant, and only requests authenticated as that tenant may read it. If the request cannot prove which tenant it represents, RLS either denies the query or returns the empty set.
The third jurisdiction of every agentic workflow is the database. Pydantic and TypedDict cannot reach it.
Each one enforces a different thing. Each one runs at a different moment. Each one fails differently. The mistake is to assume that one substitutes for another. Pydantic validating the agent's state does not check authorization. TypedDict describing the shape does not check authorization. The static type checker has no opinion about whose data the agent should be reading because it cannot see the database, and even if it could, the question is not about types.
Why teams default to bypassing RLS
The service-role key is the path of least resistance. It works on day one. It works during the demo. It works in the development loop where every test is run by the developer's own user, and "the right tenant" is whichever tenant the developer happened to log in as. The LangGraph documentation shows TypedDict state and tool calls. The Supabase client examples show service-role connections. Nothing on either side surfaces the per-invocation authorization step.
The Pydantic and TypedDict layers cannot express tenant scope. You can put a tenant_id field on the Pydantic state model, and the team will, and the validation will pass cleanly. None of that prevents the agent from issuing a query that ignores the tenant_id and returns rows it should not have access to. The boundary between the validated state and the database is not made of types.
Your service-role key is how cross-tenant leaks happen at 2 AM.
Teams reach for the service role because nothing else makes the integration smooth. Then they ship. Then a stale context object meets a real query and the next conversation is with the customer's data-protection officer.
The safe pattern: per-invocation RLS context
The fix is to give each agent invocation its own database identity. Not the service role. Not the logged-in app user. A short-lived per-invocation JWT, issued at the LangGraph entry point, carrying the claims the RLS policies need to read.
The JWT carries the tenant_id of the invocation. The Supabase client for that invocation attaches the JWT to every request. PostgREST forwards the JWT claims into the Postgres session. RLS policies read the claims with auth.jwt() ->> 'tenant_id' and apply the per-tenant filter automatically on every query. The agent never holds the service-role key. The wrong-tenant query becomes impossible: even if the agent's prompt was constructed wrong, the database refuses to return rows from another tenant because the JWT claim does not match.
A LangGraph node executor that builds the tenant-scoped client once at invocation time and threads it through the node graph keeps the pattern contained. The agent never sees the service-role key. The Supabase JWT secret stays on the server, never in agent context. The per-invocation token expires in seconds, so even if an agent prompt logs the token, the leak window is small.
Authorization is not a type. It is a runtime claim about who is asking.
What Pydantic and TypedDict will not save you from
Validating the agent's input state does not check authorization. Typing the state does not check authorization. Static analysis cannot see across the database boundary, because the boundary is between memory and storage, between the agent's process and the Postgres session, between the agent's prompt and the per-tenant rows.
This is the same shape as the gap the Python typing article described. Pydantic validates data when it arrives. TypedDict describes the data for the type checker. Both are necessary. Neither closes the third jurisdiction. The code-generation step that bridges Pydantic and TypedDict in the typing article is the analog of the per-invocation JWT step that bridges the agent and the database. The same structural pattern in a different domain.
The Pydantic-validated state does not know whose data it should be allowed to read. That knowledge lives at the database.
Audit questions for your existing agentic codebase
The cleanest way to know whether your agent is on the right side of the third boundary is to ask three questions about it. Answer them in your codebase, not in your head.
Which jurisdiction does your agent's data access live in right now?
If you want a second pair of eyes on the RLS context before it ships another generation of cross-tenant queries into production, 2muchcoffee can help through the AI development page or the contact form.