Chapter 13 · The data layer
Who is allowed to see what
Permissions decided before retrieval exists, because the metadata that makes filtering possible is written during ingestion. Three models, and the one that survives contact with a real company.
10 min read
This is the most important chapter in section 2, and it is placed here rather than in section 10 with the rest of the security material for a structural reason: the filter lives in the retrieval query, the data that filter needs is attached during ingestion, and ingestion has not been built yet. Decide this now and the metadata gets written correctly the first time. Decide it later and you re-ingest the corpus.
It assumes the inventory and the schema from chapter 8.
Where the filter goes
Three candidate places. Only the first is a control.
| Where | What it is | Verdict |
|---|---|---|
| In the retrieval query | Forbidden chunks are never fetched | ✅ Correct |
| After retrieval, before the prompt | Fetched, then dropped in application code | ⚠️ Works, fails silently |
| In the prompt | The model is asked not to reveal things | ❌ Not a control |
The third is how most first attempts work, because it is one sentence and no schema changes:
You must not reveal margin, cost or credit information to sales users.
This fails for a reason that has nothing to do with model quality. A rule in a prompt is a request evaluated by a non-deterministic system against instructions that may conflict with other instructions, including instructions arriving in the retrieved documents themselves — which is the prompt injection failure mode, and chapter 117 shows it working against exactly this setup. There is no log line recording that the model decided to comply, and nothing to audit afterwards.
The second option is better and still wrong in a specific way: the forbidden data reached your application process, which means it reached your logs, your traces and any error report that serialises the context. The blast radius of a bug is the whole retrieved set rather than nothing.
Permissions belong in the
whereclause. Everything else is a preference the system expresses, not a boundary it enforces.
The three models you will meet
Real companies express access in one of three ways. Recognising which one you are dealing with determines the schema.
Most companies are the first two at once, and Atlas is exactly that: classification for policy and financial material, ownership for contracts. The third exists wherever a document store with per-folder sharing has been in use for a decade, and it is the hard case.
The schema that handles the first two
-- From chapter 8, with the reasoning now explicit.
alter table documents
add column classification text not null, -- role-based axis
add column account_id text; -- ownership axis, null when N/A
-- A check constraint, so an unclassifiable document cannot be inserted. A
-- document with no classification would be filtered out of every query, which
-- looks exactly like an ingestion bug and takes a day to find.
alter table documents add constraint documents_classification_valid
check (classification in ('public_internal', 'account', 'financial'));
alter table documents add constraint documents_account_consistency
check ((classification = 'account') = (account_id is not null));The second constraint is the one worth copying. It makes two bugs impossible: an account-scoped document with no account, which is invisible to everyone, and a classification-scoped document that carries a stray account id, which is visible to a smaller set than intended for reasons nobody can see.
Resolving the asker happens once per request.
// src/retrieve/scope.ts — the asker's permissions, resolved to plain data.
export interface Scope {
userId: string
classifications: string[] // which classifications they may see
accountIds: string[] // which accounts they own
warehouseIds: string[] // for the tools in section 6
}
/** Resolved from the systems of record, cached briefly, never trusted from
* the client. A scope that arrives in a request body is an authorisation
* bypass with extra steps. */
export async function resolveScope(user: User): Promise<Scope> {
const [accounts, warehouses] = await Promise.all([
db.query(`select id from accounts where owner_id = $1`, [user.id]),
db.query(`select warehouse_id from user_warehouses where user_id = $1`, [user.id]),
])
return {
userId: user.id,
classifications: CLASSIFICATIONS_BY_ROLE[user.role],
accountIds: accounts.rows.map((r) => r.id),
warehouseIds: warehouses.rows.map((r) => r.warehouse_id),
}
}
const CLASSIFICATIONS_BY_ROLE: Record<Role, string[]> = {
inside_sales: ['public_internal', 'account'],
warehouse_supervisor: ['public_internal'],
finance: ['public_internal', 'account', 'financial'],
}And the filter itself, which is four lines of SQL and the entire defence:
select c.id, c.content, 1 - (c.embedding <=> $1) as score
from chunks c
join documents d on d.id = c.document_id
where d.deleted_at is null
and d.classification = any($2) -- role axis
and (d.account_id is null or d.account_id = any($3)) -- ownership axis
order by c.embedding <=> $1
limit $4;Make the unsafe query impossible to write
A correct filter that can be omitted will be omitted — in a new endpoint, in a debugging script that gets committed, in an eval harness that becomes production code. Three defences, in increasing order of strength.
// 1. Required parameter. The compiler checks every call site.
export function retrieve(question: string, options: { scope: Scope; limit?: number }) { }
// 2. One query builder. Nothing else writes SQL against chunks.
function scopedChunkQuery(scope: Scope) {
return {
where: `d.deleted_at is null
and d.classification = any($class)
and (d.account_id is null or d.account_id = any($accounts))`,
params: { class: scope.classifications, accounts: scope.accountIds },
}
}
// 3. A test that fails if a new query path forgets. This is the one that
// actually holds, because it keeps working after everyone has forgotten
// the convention.
test('every chunk query is scoped', async () => {
const sources = await readAll('src/**/*.ts')
const unscoped = sources.filter(
(f) => /from\s+chunks/i.test(f.text) && !f.text.includes('scopedChunkQuery'),
)
expect(unscoped.map((f) => f.path)).toEqual([])
})The third defence is crude — it is a grep with a test runner around it — and in practice it is the one that catches the real case, six months later, when somebody adds a "related documents" feature and writes a fresh query.
The ACL case
If the company's access model is per-document lists, the schema changes shape.
create table document_grants (
document_id text not null references documents(id) on delete cascade,
principal text not null, -- a user id or a group id
primary key (document_id, principal)
);
create index document_grants_principal on document_grants(principal);-- Retrieval with an ACL join. Correct, and measurably slower.
select c.id, c.content, 1 - (c.embedding <=> $1) as score
from chunks c
join documents d on d.id = c.document_id
where d.deleted_at is null
and exists (
select 1 from document_grants g
where g.document_id = d.id and g.principal = any($2) -- user + their groups
)
order by c.embedding <=> $1
limit $3;Two warnings on this path, both learned expensively by people who have taken it.
Groups nest. A principal list has to be the transitive closure of the asker's group memberships, computed per request or cached with a short TTL. Resolving one level deep is a bug that denies access rather than granting it, which at least fails safely.
Grants drift. The grants in your table are a copy of grants in another system, and they go stale. The staleness window is a security window — a revoked grant that is still in your copy is a document the person can still reach. Sync grants more aggressively than content, and treat a grant sync failure as an incident rather than a warning. Chapter 34 covers the sync; the decision that grants are the high-priority part of it belongs here.
The failure mode: permissions that work for the first slice
The version of this that goes wrong is subtle because it is correct, briefly.
Atlas's first slice is inside sales and documents. Inside sales sees public_internal and their own account documents. The filter works. Thirty-four questions pass, the forty leakage cases pass, and it ships.
Then warehouse supervisors are added. A supervisor sees public_internal only, which is simple. But a supervisor asking "where is the Henderson order" needs the orders table, and the orders table is reached through a tool, and the tool has its own filter written by whoever wrote the tool. There are now two permission implementations.
By the time finance is added there are four, they disagree about one edge case, and nobody can state what any individual may see without reading four files.
The structural defence is to make scope resolution the only place permissions are computed, and have every path — retrieval, every tool, every workflow — consume the same Scope object rather than deriving its own.
// Every tool takes the same scope. None of them resolves permissions itself.
export interface Tool<I, O> {
name: string
description: string
run(input: I, scope: Scope): Promise<O>
}That interface signature is a small decision made in chapter 13 that saves section 6 from being a security review. Chapter 119 returns to enforcement at query time with the threat model attached.
Key points
- The filter goes in the retrieval query. After-the-fact filtering leaks into logs and traces; prompt instructions are not controls.
- Companies express access as roles, ownership, or per-document lists. Most are the first two at once.
- Constrain the schema so an unclassifiable or inconsistently scoped document cannot be inserted.
- Resolve scope server-side from the systems of record. A scope arriving in a request body is a bypass.
- A test that greps for unscoped chunk queries is crude and is the defence that still works in six months.
- With ACLs: resolve group membership transitively, and sync grants more aggressively than content.
- One scope object, consumed by retrieval and every tool. Four permission implementations is the predictable end state otherwise.
Next: reading the permission model you already have — getting from "it is managed in the CRM" to a column name, which is harder than it sounds.