01 / The prompt
“Build a hiring tool that many small companies can share.”
Juniper, a dental practice, and Kestrel, a bike shop, both hire through the same tool. Each has a workspace with its candidates and interview notes, and the notes say things like “asks for $120k” and “not a fit”. An agent given the request will answer it one of three ways, and all three work in the demo:
- A
tenant_idon every table, and every query filtered by it. - The same column, with row-level security: the database itself hides other companies’ rows.
- A database per company, chosen per request.
Each keeps the handlers you review apart. What differs is the handler nobody reviews: an export added in a hurry, a table added next month, a background job, a connection change to fix a permission error. In the lesson’s example, a notes export written without the filter hands Juniper 1 of Kestrel’s notes when the filter lives only in the queries.
The brief never answered: when a later change forgets the tenant, which layer still holds, and does it fail by showing nothing or by showing everything?
This is not hypothetical. CVE-2025-48757 records that “an insufficient database Row-Level Security policy in Lovable through 2025-04-15 allows remote unauthenticated attackers to read or write to arbitrary database tables of generated sites” (CVE record, published 30 May 2025, fetched 23 September 2026). The supplier disputes it, noting that each customer is responsible for protecting their own application’s data, which is this lesson’s point: the placement is yours to choose and to check.
02 / Name the choice
Three places for the tenant filter, and each misses something different.
A tenant is one customer company, and its tenant context is the company a request acts for, taken from the signed-in user’s membership. The filter can live in the query layer (every query binds the tenant), the row layer (the database applies a policy to every row, which PostgreSQL calls row-level security), or the database layer (each company has its own database).
Take the tenant from the verified membership, never from the request. Put it in two layers that fail differently, so one forgotten filter is not a leak. Give a company its own database when it needs its data, its load, or its restores kept apart, and pay for it in migrations.
| Query layer | Row policy | Both | Database per company | |
|---|---|---|---|---|
| A handler without the filter | Leaks | Covered | Covered | Covered |
| A table added without a policy | Covered, if it filters | Leaks | Covered | Covered |
| Context that outlives the request | Refuses | Leaks with SET; refuses with SET LOCAL | Refuses | Leaks if the connection is kept |
| A tenant taken from the URL | Leaks everywhere: placement does not check identity | |||
| Adding a column | One migration | One per company, and some can fail | ||
| One company’s big import | Everyone waits | Only that company waits | ||
| Restore one company to yesterday | Hard: its rows are mixed with everyone’s | Restore one database | ||
Words to put in a prompt or a review
- Tenant
- One customer company, with its own users and data inside a shared product.
- Tenant context
- The company a request acts for, from the user’s verified membership.
- Row-level security
- A database rule that filters every row by a policy, whatever the query says.
- Policy
- One table’s row rule, such as “tenant_id equals this connection’s tenant”.
- Bypass
- A role that skips policies: superusers, BYPASSRLS roles, and usually table owners.
- Database per tenant
- Each company in its own database; also called a silo. A schema per company is the middle ground.
What PostgreSQL’s row-level security does and does not coverFrom the PostgreSQL 18 documentation
Once a table has row security enabled, “if no policy exists for the table, a default-deny
policy is used”, so a forgotten policy on an enabled table shows nothing. A table that was
never enabled has no row security at all, which is the case in the story. And some roles
skip it: “Superusers and roles with the BYPASSRLS attribute always bypass the
row security system when accessing a table. Table owners normally bypass row security as
well”, unless the table uses FORCE ROW LEVEL SECURITY. Finally, “Referential
integrity checks … always bypass row security” (Row Security Policies, fetched 23 September 2026), so a foreign key alone will let Juniper point at Kestrel’s
row; include tenant_id in the key.
A schema per company sits between the shared tables and a database per company: one
database, separate tables, a search_path per request. It shares the database layer’s
leftover-context risk and its per-company migrations, and not its separate load or restores.
03 / Follow one request
Juniper’s recruiter sends five requests. Which placements hold?
Each card runs the lesson’s leak probe against one placement. The first request is the one everyone reviewed; the next four are what later changes add. Then open Try it, pick a placement and a request, and turn on Kestrel’s import.
One recruiter, five requests, four placements
Query layer
Juniper’s request is on its way…
Row policy
Juniper’s request is on its way…
Query layer and row policy
Juniper’s request is on its way…
A database per company
Juniper’s request is on its way…
Juniper’s recruiter lists candidates.
The handlers everyone reviewed. Every placement answers with Juniper’s two candidates. A demo stops here.
Reduced motion: choose a scene to see its completed state.
Read this scene
The handlers everyone reviewed. Every placement answers with Juniper’s two candidates. A demo stops here.
Query layer: Only Juniper’s rows.
Row policy: Only Juniper’s rows.
Query layer and row policy: Only Juniper’s rows.
A database per company: Only Juniper’s rows.
Watch restarts the story when you come back. Step through shows where each chapter ends. Try it runs the probe for the placement and the request you pick.
04 / Read the shape
A repository, a row policy, and a database chosen per request.
Each tab is one of the placements. Basic form is the query layer, In the wild is the row layer, and At the call site is the database layer. Notice where each one’s tenant comes from, and how long it lives.
The query layer: a repository that binds the tenant as a parameter on every query and refuses a query with none. A handler that reads the table without it is not covered.
/**
* The query layer: a repository that binds the tenant as a parameter on every
* query. It refuses a query with no tenant. It cannot help a handler that
* reads the table without going through it.
*/
export class TenantRepository {
conn: Connection;
constructor(conn: Connection) {
this.conn = conn;
}
read(table: Table, tenant: Tenant | null): Row[] {
if (tenant === null) throw new NoTenant('no tenant for this query');
return this.conn.select(table, { tenant_id: tenant });
}
} // TenantRepository is the query layer: it binds the tenant as a parameter on
// every query and refuses a query with no tenant. It cannot help a handler that
// reads the table without going through it.
type TenantRepository struct{ Conn *Connection }
func (r TenantRepository) Read(table, tenant string) ([]Row, error) {
if tenant == "" {
return nil, ErrNoTenant
}
return r.Conn.Select(table, tenant), nil
} The behavior these examples promiseChecked by 24 shared cases
- Every placement answers the reviewed handler with Juniper’s rows only.
- The query layer alone leaks through a handler that skips the filter; the row layer alone leaks through a table with no policy; the database layer and a session-wide row setting leak when the request’s context outlives it. A repository handed no tenant refuses.
- Both layers together cover every probe but one.
- A tenant taken from the URL leaks in every placement: none of them checks who the user is.
Every expectation was generated by a separate model written from the contract in the examples’ README, not copied from either implementation, and it is kept beside the examples. The example models a database; it is not PostgreSQL.
Reading the TypeScriptOne Scope, four placements
Every placement implements Scope: setUp at the start of a
request, then read. The tenant in a where is always a bound value
compared with a row’s field, never text pasted into a query; the lesson’s old template built
SQL by concatenating the tenant, and this one never does.
Reading the GoAn interface and a sentinel error
The placements satisfy one Scope interface. A repository with no tenant
returns ErrNoTenant, and the probe reports it as a refusal rather than an
empty list, because “nothing” and “not allowed” should look different in a log.
Run it yourselfNo dependencies
Save the complete files at the paths in their banners. Then run node --experimental-strip-types run.ts (Node 22.18 or later), or go run . in the Go folder. Both print:
query: unfiltered-handler leaked 1, unprotected-table ok, leftover-context refused, tenant-from-url leaked 1 row: unfiltered-handler ok, unprotected-table leaked 1, leftover-context leaked 1, tenant-from-url leaked 1 query+row: unfiltered-handler ok, unprotected-table ok, leftover-context refused, tenant-from-url leaked 1 database: unfiltered-handler ok, unprotected-table ok, leftover-context leaked 1, tenant-from-url leaked 1
05 / Review the agent’s diff
“I pointed the app at the migrations connection.”
The hiring tool runs on PostgreSQL with row-level security, and the migrations role owns every table. A new offers page failed with a permission error, and an agent fixed it.
06 / How it fails
A tenant boundary fails open or closed. Choose closed.
The first five rows are shared cases the tests run; the last three are not modeled.
| What happens | What Juniper sees | What holds it |
|---|---|---|
| A handler reads without the filter | Kestrel’s notes, with the query layer alone | A row policy or a separate database |
| A table is added without a policy | Kestrel’s offers, with the row layer alone | The query filter, or a separate database |
| The tenant outlives the request | Kestrel’s candidates, from a session-wide setting or a kept connection | SET LOCAL per transaction; a repository that must be handed the tenant |
| The tenant comes from the URL | Kestrel’s candidates, everywhere | A membership check before any placement |
| Kestrel imports 5,000 candidates | Slow pages in a shared database | A per-company limit, or a database of its own |
| The app connects as the table owner | Everything | A separate app role, or FORCE ROW LEVEL SECURITY. Not modeled. |
| A migration fails on some company databases | Errors on some workspaces only | Migrations that can run twice, and a per-database version check. Not modeled. |
| A cache key without the tenant | Juniper’s cached list under Kestrel’s name | The tenant in every cache key; see the frontend row. Not modeled. |
A refusal is a bug report; a leak is an incident. That is why the probe counts a refused request as passing. Validation at the edge covers why the tenant must come from what the server verified, not from what the client sent.
07 / Is it worth it?
A second layer costs a policy per table. A database per company costs a fleet.
| Change | Query layer | Query layer and row policy | Database per company |
|---|---|---|---|
| A second entry point: a nightly job that emails each company a digest | It must remember the filter, per company. | It must set the tenant per company; without it, it reads nothing. | It opens each company’s database in turn. |
| Replacing the database | Portable: the filter is in the code. | Row-level security is database-specific; the policies move or are lost. | Portable, times the number of companies. |
| A new rule: agency recruiters work for several companies | No difference: membership already decides the tenant per request. | ||
| A data team wants hiring numbers across companies | One query as an audited reporting role that may bypass the policy. | One query per database, or a warehouse fed from each. | |
The costs: a policy and a grant in every migration that adds a table, a transaction around
every request to hold SET LOCAL, and a reporting role you must audit. A
database per company adds a migration per company, connection routing, and per-company
backups; it buys separate load, restores, and a boundary a stray query cannot cross.
Measure before and after:
- Rows from another tenant in the probe, per route, on every change. The accepted result is zero, and a refusal counts as zero.
- Rows returned whose tenant is not the session’s, counted at runtime by the repository; any non-zero value is an incident.
- p95 latency for small companies while a large one imports, and migration time across all databases.
This lesson did not measure a real product, and gives no numbers.
08 / Ask for it
One brief, two prompts, then one ticket.
Two agents running Claude Sonnet each got the brief from section 01. One prompt added a Tenant isolation block asking for the database layer: a SQLite file per workspace, opened only after the membership check and never kept between requests. Then each build went to a fresh agent with a ticket for offers and a notes export, the new route and new table this lesson is about. A script signed in as Juniper’s recruiter and probed every route for a marked secret in Kestrel’s notes, and did the same to a control build we wrote with one unfiltered export.
| Question | Plain prompt | Tenant-isolation prompt | Control (not an agent) |
|---|---|---|---|
| Six probes of the routes in the brief | No leak; 403, 403, 403 for Kestrel’s workspace | No leak; 403, 403, 403 for Kestrel’s workspace | No leak; 403, 403, 403 for Kestrel’s workspace |
| After the ticket: export, offers, an offer through Juniper | No leak; the offer through Juniper answered 404 | No leak; the offer through Juniper answered 404 | The export shows Kestrel’s interview note |
| The export with its tenant condition removed | Shows Kestrel’s interview note | Nothing to remove; no leak | Not asked |
| Its own tests | 19 of 19; after the ticket 33 of 33 | 17 of 17; after the ticket 34 of 34 | None |
Neither build leaked, before or after the ticket. The plain agent’s brief said “a company must never see … another company’s candidates or notes”, and it wrote a workspace condition into every query; the agent who picked up the ticket followed that pattern into the new export, down to matching the join on the workspace:
const exportNotes = db.prepare(
`SELECT candidates.name AS candidate, notes.author AS author, notes.body AS body
FROM notes
JOIN candidates ON candidates.id = notes.candidate_id AND candidates.workspace = notes.workspace
WHERE notes.workspace = ?
ORDER BY notes.created_at ASC`
); The files agent’s export has no tenant condition at all, and needs none: the connection it was handed is Juniper’s file.
function handleExportNotes(res: ServerResponse, db: DatabaseSync): void {
const rows = db
.prepare(
`SELECT candidates.name AS candidate, notes.author AS author, notes.body AS body
FROM notes
JOIN candidates ON candidates.id = notes.candidate_id
ORDER BY notes.rowid ASC`,
)
.all() as unknown as ExportNoteRow[]; So the checker asked the question this lesson is about: what happens when the next person forgets? It took the workspace condition out of the plain build’s export and probed again, and Juniper’s recruiter got Kestrel’s interview note with the salary in it. There was nothing to take out of the files build. Both builds have exactly one layer, and the plain build’s one layer is a line every future query must remember.
The missing line is the second layer: isolate below the handler as well as in it, so a query that forgets the tenant returns nothing instead of everything. With PostgreSQL that is row-level security on every tenant table; with a database per company it is the connection itself. The product sentence got a correct build. It did not get one that survives the next person.
How the runs were made and checkedFour builds, two rounds, recorded as written
- Each pair of agents was launched at the same time; no agent was told about the other, the lesson, or the checker. Round two started from byte-for-byte copies of round one.
- All four builds are kept with checksums. The checker writes its own users file and restores each build into a fresh folder. The edit that removes the plain export’s condition is written out in the checker, not hidden.
- The checker’s first run did not yet ask the forgotten-condition question; its other answers were the same.
- Three agents wrote scratch files to
/tmpagainst the prompt; one left an empty file, which we deleted, and one left a hung shell, which we stopped by its process id. No agent stopped a process by name or pattern. - One run of each prompt is a sample, not a measurement of the model.
09 / Hold it there
Every new route and table is a new chance to forget. Probe them all.
Let the database refuse
In PostgreSQL, enable row security in the migration that creates the table, connect the app as a role that owns nothing, and set the tenant with
SET LOCALinside each request’s transaction. The documentation’s own words are the checklist: owners andBYPASSRLSroles skip policies, and an enabled table with no policy denies everything.A rule a check enforces
Only the repository may run a query against a tenant table. An import rule, the way Enforcement layer enforces one, turns that sentence into a failing build; Architecture as rules covers writing it. This lesson did not run such a rule on the recorded builds.
Probe what ships
The probe is the check that matters, because it tests behavior rather than code shape: sign in as one company, call every route with the other company’s ids and slug, and fail on any row that comes back. The checker in section 08 does exactly this against the recorded builds:
check-runs.mjs const byId = await ana('GET', `/w/juniper/candidates/${ids.kestrel}`); const noteById = await ana('POST', `/w/juniper/candidates/${ids.kestrel}/notes`, { body: 'planted by Ana' }); const kestrelView = await s.call('tok-kai', 'GET', `/w/kestrel/candidates/${ids.kestrel}`); q["Ana uses Kestrel's candidate id in her own workspace"] = { statuses: [byId.status, noteById.status], leaked: shows(byId, ids), wroteIntoKestrel: kestrelView.text.includes('planted by Ana') };
Where this lives in React and SvelteThe workspace is already in your URL and your cache keys. Switching company is where it leaks.
Where it already is in your components
Any app with a workspace switcher has the tenant in its routes, /w/juniper/candidates, and should have it in its cache keys. A TanStack Query key of ['w', workspace, 'candidates'] makes Juniper’s list and Kestrel’s list two entries; a SvelteKit load keyed
by params.workspace reruns when it changes.
When you have to own it
The switcher. A cache keyed only by 'candidates' shows Juniper’s list under Kestrel’s
name until the refetch lands, and a shared laptop keeps Juniper’s notes in memory after the
recruiter moves on. Key everything by workspace, and remove the old workspace’s entries on a
switch. The server’s membership check is still the boundary; the client only avoids showing
what it should have dropped.
The candidate list, keyed by workspace. React uses a TanStack Query key with the workspace in it; SvelteKit loads by the route parameter.
import { useQuery } from '@tanstack/react-query';
type Candidate = { id: string; name: string };
// The workspace is already in two places: the route (/w/juniper/candidates),
// which passes it in, and the cache key. Keying the query by workspace means Juniper's list and
// Kestrel's list are two cache entries, never one.
export function Candidates({ workspace }: { workspace: string }) {
const candidates = useQuery({
queryKey: ['w', workspace, 'candidates'],
queryFn: async (): Promise<Candidate[]> => {
const response = await fetch(`/w/${workspace}/candidates`);
if (!response.ok) throw new Error(`candidates: ${response.status}`);
return (await response.json()).candidates;
}
});
if (candidates.isPending) return <p>Loading candidates…</p>;
if (candidates.isError) return <p>Could not load candidates.</p>;
return (
<ul>
{candidates.data.map((c) => (
<li key={c.id}>{c.name}</li>
))}
</ul>
);
}
10 / Make the call
Two layers for everyone; a database of its own for whoever needs it.
For the hiring tool I would keep every company in shared tables with a tenant_id, filter every query through a repository that binds it, and enable row-level security on
every tenant table as a second layer, with the tenant set per transaction and the app on a
role that owns nothing. I accept a policy per table, a transaction per request, and an
audited reporting role. I would give a company its own database when it asks for its data to
be kept apart by contract, when its load starts to slow everyone else, or when it needs its
own restores, and I would reconsider the shared design if that becomes most companies.
A query filter alone is fine for a prototype with a handful of handlers written by one person. The day a second person, or an agent, adds a route, add the second layer.
Keep a note of the decision
- Why
- Many small companies share one hiring tool; interview notes hold salaries and hiring decisions; routes and tables will be added by people and agents who never saw the first handler.
- What
- Shared tables with
tenant_id, a repository that binds it, and row-level security on every tenant table; the tenant from the verified membership. - Constraint
- Each layer misses something different, so one forgotten filter or policy is not a leak; a refusal is better than a leak.
- Fallback
- A policy and grant in every migration,
SET LOCALin every request, an audited bypass role for reports; one company’s import can slow the others. - Reconsider when
- A company needs its data, its load, or its restores kept apart; then that company gets a database of its own.
Take it with you
Explain it without saying “multi-tenancy”: “Many companies use our app, and each must only ever see its own things. We check which company you belong to when you arrive, and then two different parts of the system each refuse to hand over another company’s things, so if one of them is forgotten, the other still says no.” Then pick the newest route in your own app and ask what it returns when you call it with another customer’s id.
Paste into your next prompt, and fill in the blanks
Every table that holds [a company]'s data has tenant_id NOT NULL, and every reference includes it. The tenant comes from the verified session's membership, never from the URL, the body, or a header the client sets. Two layers, so one mistake is not a leak: - every query goes through [a repository] that binds tenant_id as a parameter and refuses to run without one; - [PostgreSQL] row-level security on every tenant table, enabled in the same migration that creates it, with the tenant set per transaction (SET LOCAL app.tenant) and the app connecting as a role that does not own the tables. A test signs in as one company and calls every route, including new ones, and fails on any row from another company. [Company X] gets its own database only if [it needs its data, load, or restores kept apart], and migrations run for every database.
Connections to follow nextRelated lessons
- Client–server architecture is where “anything the browser sends is a request, not a fact” starts.
- Module-owned data in one database draws the same kind of line inside one company’s data, between modules.
- Change data capture copies tables out of the database; the copies need the tenant too.
- Containing failure is the noisy neighbor from the other side: one caller slowing everyone.
- Validation at the edge is why the tenant comes from the session, not the request.