Back

A Privacy Filter Below the Prompt

By Pablo Fernandez12 min read

The cheapest privacy control for an AI agent is one sentence in the system prompt: "Never reveal the customer's bank account." It costs nothing and it reads well in a design review. It is also a request made to the one component in the system you have no reason to trust.

The model reads invoice notes, scanned receipts, emails from strangers and messages forwarded from WhatsApp. Any of those can carry text written to steer it, which is what OWASP's top risk for LLM applications describes. A rule in the prompt holds as long as nothing in the context argues against it. That is not a filter. It is a hope with good manners.

Sumalos is my invoicing app for freelancers in Spain, and its assistant, Suma, works on a business's real records: contacts, invoices, expenses, scanned ID cards. Each organization decides which fields Suma must never see, even when the person typing to it can. So the question this post answers is where that filter has to sit to keep working when the model doesn't cooperate. My answer: below the prompt, in the code that hands data to the model, and in Postgres under that.

One door for every read

A filter you can walk around is decoration, so the first job was making sure there is exactly one way in. Every tool Suma has (search, query, read a record, list memories) gets a ToolEnv with a data module in it, and that module is the only thing that touches the database. The tools are not allowed to import the database at all, and a test reads their source to make sure:

packages/server/src/ai/security/boundary.test.ts
/** Tools must not even build SQL: that lives in ai/data and ai/query, behind the proxy. */
const FORBIDDEN_IN_TOOLS = [...FORBIDDEN_EVERYWHERE, /from\s+["']drizzle-orm["']/, /^import\s+(?!type\b)[^;]*from\s+["']@sumalos\/db["']/m];

Inside the module, every read follows the same order: run the query in a scoped transaction, mask what came back, write the audit row, hand it to the tool.

packages/server/src/ai/data/index.ts
const read = async <T>(access: AiAccess, tool: string, query: unknown, run: (scoped: Db) => Promise<T>, records: (out: T) => Record<string, string[]>, perStatement = false, post?: (out: T) => Promise<T>) => {
  // The guardrails (docs/guardarrailes-ia.md): reads per request, counted here whatever tool asks.
  requestGuardOf(access.trace?.requestId)?.dataRead();
  const raw = perStatement ? await run(scope.perStatement(access)) : await scope.tx(access, run);
  // Masking runs after the scoped transaction (it reads the policy and the vault as the app user).
  const out = post ? await post(raw) : raw;
  const found = records(out);
  const rows = Object.values(found).reduce((n, ids) => n + ids.length, 0);
  void audit.access({ access, tool, operation: "read", query, records: found, resultCount: rows });
tool call (buscar, consultar, leer, recuerdos ...) │ ▼createAiData ────── the only module allowed to read │ ▼scope.tx ────────── restricted role + the request's settings; │ Postgres applies row security and ai_* views ▼privacy pass ────── fields by path, then a net over free text │ ▼audit ───────────── ids and counts, never values │ ▼model
Every read Suma makes passes the same four stages, in this order, before the model sees anything.

The rest of this post walks through those stages one at a time, following a single value: a customer's phone number that the organization has decided Suma should only see the last four digits of.

A policy the organization writes

The policy is data, one row per organization in ai_privacy_policies, edited by owners and admins on a settings page. A rule is a field path and what to do with it:

packages/db/src/ai-privacy.ts
/** How a masked field reaches the model. */
export type AiPrivacyStrategy = "redact" | "mask_partial" | "drop" | "pseudonymize";
 
/** One rule: a field path pattern (`contact.phone`, `*.iban`, `contact.*phone*`) and what to do with it. */
export interface AiPrivacyRule {
  pattern: string;
  strategy: AiPrivacyStrategy;
  /** mask_partial: how many characters stay at the end (4 by default). */
  keep?: number;
}

A path is an entity and a field: contact.phone, invoice.customer.address, document.dni.number. A * matches anything, dots included, and a bare word like iban means that field anywhere. Paths compare without case, underscores or dashes, so a rule written as contact.tax_id matches the row key taxId. Besides rules, a policy can exclude whole entities ("no expenses at all"), and then queries and reads of that entity are refused outright.

When several rules match a field, the strictest one wins. That single ordering is what keeps the system predictable:

packages/server/src/ai/data/privacy/engine.ts
/** When several rules match, the strictest wins: an override can only make things stricter. */
export const STRICTNESS: Record<PrivacyStrategy, number> = { mask_partial: 1, pseudonymize: 2, redact: 3, drop: 4 };
defaults ──────────────┐organization rules ────┼──▶ effective policy ──▶ strictest match per fieldconversation rules ────┘mask_partial < pseudonymize < redact < drop****0123 ‹NIF-3f9a12c4d0› [OCULTO] (the key is gone)
Three sources of rules merge into the policy for one request; for each field, the strictest matching strategy is the one applied.

The defaults are on unless an organization turns them off: IBANs and card numbers keep their last four characters, DNI and NIE numbers (Spanish ID cards) likewise, and birth dates, passwords, PINs, CVVs, tokens and API keys are redacted or dropped. A trimmed excerpt:

packages/server/src/ai/data/privacy/catalog.ts
export const DEFAULT_RULES: readonly (PrivacyRule & { label: string })[] = [
  { pattern: "*.iban", strategy: "mask_partial", keep: 4, label: "IBAN, solo sus últimas cifras" },
  { pattern: "*.dni", strategy: "mask_partial", keep: 4, label: "números de DNI y NIE, solo sus últimas cifras" },
  { pattern: "*birth*", strategy: "redact", label: "fechas de nacimiento" },
  { pattern: "*.cvv", strategy: "drop", label: "códigos CVV" },
  { pattern: "*password*", strategy: "drop", label: "contraseñas" },
  { pattern: "*api*key*", strategy: "drop", label: "claves de API" },
];

One thing the defaults leave visible on purpose: a company's tax ID (NIF or CIF). It is printed on every invoice the company sends, and an invoicing assistant that can't see it can't do its job.

A single conversation can add rules on top, and the code checks that it only tightens: atLeastAsStrict compares the new policy against the old one for every field path the catalog knows and refuses the change if any field would become more visible. Every change to an organization's policy goes into the audit log with the patterns before and after, never values.

There is one honest gap here. Tool results from earlier turns that the web sends back in the transcript were masked under the policy in force at the time. If an owner tightens the policy mid-conversation, those older results stay as they were until they fall out of the recent window.

Rows the role can't see

The first stage is plain Postgres. Each read opens a transaction that switches to a role with no login, sumalos_ai, and sets who is asking as transaction-local settings, all in one statement:

packages/server/src/ai/data/scope.ts
return sql`select
  set_config('app.principal_user', ${user}, true),
  set_config('app.principal_phone', ${phone}, true),
  set_config('app.org_ids', ${orgs}, true),
  set_config('app.perms', ${JSON.stringify(perms)}, true),
  set_config('app.channel', ${channel}, true),
  -- The database backstop (docs/guardarrailes-ia.md): no AI read runs long or idles in a transaction.
  set_config('statement_timeout', ${String(guardRuntime().settings().statementTimeoutMs)}, true),
  set_config('idle_in_transaction_session_timeout', ${String(guardRuntime().settings().statementTimeoutMs * 2)}, true)
  ${role ? sql`, set_config('role', ${role}, true)` : sql``}`;

The third argument of set_config is is_local: the values vanish at commit. Row-level security policies then read those settings through small SQL functions:

packages/db/drizzle/0041_ai_rls.sql
-- May the principal read (or write) `area` in `org`? Only for an organization of the request.
CREATE OR REPLACE FUNCTION ai_can(org uuid, area text, need text) RETURNS boolean LANGUAGE sql STABLE AS $$
	SELECT org = ANY(ai_org_ids()) AND coalesce((ai_perms() -> org::text -> need) ? area, false)
$$;
packages/db/drizzle/0041_ai_rls.sql
SELECT ai_rls_protect('contacts', 'ai_can(organization_id, ''contacts'', ''read'')');
SELECT ai_rls_protect('invoices', 'ai_can(organization_id, ''invoices'', ''read'')');
SELECT ai_rls_protect('expenses', 'ai_can(organization_id, ''expenses'', ''read'')');

With no settings, ai_org_ids() is an empty array and the role sees nothing. That is the property I care about: a query that forgets its WHERE organization_id = ... still returns only what this person may read, because the database adds the filter itself. The tables are also set to FORCE ROW LEVEL SECURITY, with a separate pass-through policy for the app's own user, so the app is unaffected and the assistant's role only ever gets the ai_read policy.

Rows are half of it. The role reads views, not tables, and the views drop or rewrite columns before any code of ours runs. The invoice view replaces the issuer's IBAN with its last four digits:

packages/db/drizzle/0041_ai_rls.sql
CREATE OR REPLACE FUNCTION ai_mask_iban(value text) RETURNS text LANGUAGE sql IMMUTABLE AS $$
	SELECT CASE WHEN coalesce(value, '') = '' THEN value ELSE '•••• ' || right(regexp_replace(value, '\s', '', 'g'), 4) END
$$;

The views are created WITH (security_invoker = true), so the table policies apply to whoever reads the view rather than to its owner. Quotes lose the IP address and browser of whoever accepted them, files lose their names and storage keys, and the role has column-level grants on the rest: asking for a column outside the list gets "permission denied" from Postgres, not an empty value.

This stage is the same for every organization. It enforces who may see a row; it knows nothing about an organization's choice to hide phone numbers. The phone number from our example passes through it untouched, because the person asking may read contacts.

There is a known limit. A session that did SET ROLE can switch back, so this layer assumes no SQL comes from the model. None does: the query tool compiles an allowlisted structure into parameterized SQL. Giving the assistant its own login role and connection pool, so that switching back isn't possible at all, is the planned next step for production.

Fields, by path

The second stage is where the organization's policy applies, in maskEntity. It walks a result shaped like an entity and builds a field path from each key: the root names the entity, each nested key adds a segment, and arrays add none, so the description on an invoice line is invoice.lines.description. For each path it asks for the strictest matching rule and replaces the value:

packages/server/src/ai/data/privacy/engine.ts
export function applyStrategy(rule: PrivacyRule, value: string, path: string, ctx: Pick<PassContext, "pseudonym" | "organizationId">): string | null {
  switch (rule.strategy) {
    case "drop":
      return null;
    case "redact":
      return REDACTED;
    case "mask_partial":
      return maskPartial(value, rule.keep ?? DEFAULT_KEEP);
    case "pseudonymize":
      return ctx.pseudonym(value, path, ctx.organizationId);
  }
}

A rule that matches a whole object, say invoice.customer, hides the object in one go. Keys that are ids, handles, dates or enums are skipped, so masking can't break the references the agent uses to act on a record.

row from ai_contacts what the model gets┌──────────────────────────┐ ┌───────────────────────────┐│ name María Carmona │ │ name María Carmona ││ taxId B86641234 │──────▶│ taxId ‹NIF-3f9a12c4d0› ││ phone +34915550123 │ │ phone ****0123 ││ email maria@example.com │ │ email [OCULTO] │└──────────────────────────┘ └───────────────────────────┘
One contact through the field pass, with phone kept to four digits, tax ID as a pseudonym and email redacted (example values).

[OCULTO] is Spanish for "[HIDDEN]"; the product is Spanish, so the placeholders are too. A pseudonym is a stable token per value and organization: the model can compare it and hand it back to an action, and the server swaps in the real value at execution. That swap is a subject of its own, in the clear proxy between the agent and the data.

The pass also leaves a mark on each object it changed, and the chat cards use it. When Suma shows the contact it found, the phone field on the card says "Oculto para Suma" ("hidden from Suma") instead of the number. The person can see the number in the app; the card shows exactly what the model saw.

Free text has no field names

A typed pass is precise and easy to test, and it misses the obvious case. The phone number is also in an invoice note ("call her on +34 915 55 01 23 before Friday"), in a WhatsApp message from last week, in a snippet the search engine returns, in the summary of an old conversation. None of those have a key called phone.

So everything that is free text goes through a second pass, maskTextSync, which runs in a fixed order:

"call her on +34 915 55 01 23 before Friday" │ ├─ 1. pseudonyms already in the text: held, untouched ├─ 2. values masked earlier in this request: matched │ verbatim, with or without spaces, dots, dashes ├─ 3. detectors: IBAN, cards (Luhn), DNI/NIE, CIF, │ phones, emails, each under its own field's rule ▼"call her on ****0123 before Friday"
The text net: values already masked in this request first, then detectors for kinds of value the policy masks.

The second step is the reason the pass is per request. When a tool masks María's phone in a contact record, the value is remembered for the rest of that request, so when the next tool returns a note that contains the same number with spaces in it, the net recognises it. The detectors cover values the request hasn't seen yet, and each one applies the strategy of the field its kind belongs to: if contact phones are kept to four digits, a phone found in a note is too. Card numbers have to pass a Luhn check before they count, so any long run of digits isn't treated as a card.

Replacements are held behind placeholders until the end, so a later step can't match inside something an earlier step already replaced. And when masking a scanned document fails for any reason, the document isn't shown: the failure is closed, not open.

The net has a real limit. Identifiers have shapes a regex can find. Names and street addresses don't, so a hidden address is only recognised in free text when the same request has already seen it in its record. For the values most organizations care about (bank accounts, ID numbers, phones, emails) the detectors find them without that help.

Questions whose answer leaks

Masking the result is not enough when the model can choose the question. Suppose phones are hidden and the model asks the query tool for contacts where the phone contains "555". It gets back a list of masked rows, and the list itself is the answer: the rows that came back are the ones that match. Sorting by a hidden field leaks its order. A count grouped by it leaks its distribution.

So guardQuery reads the structured query before it is compiled, and refuses to filter, sort, group or total by any masked field:

packages/server/src/ai/data/privacy/apply.ts
const refuse = (what: string, field: string) => {
  throw new QueryError(`No se puede ${what} por «${field}»: ese dato está oculto para Suma. No intentes averiguarlo de otra forma.`, "denied");
};

The message says "You can't filter by that field: it is hidden from Suma. Don't try to find it out another way", and it goes back to the model as the tool's result, so the model can tell the person why. The red-team test throws the obvious attempts at it:

packages/server/src/ai/data/privacy/privacy.test.ts
await refused({ entidad: "contactos", filtros: [{ campo: "telefono", igual: "+34915550123" }] });
await refused({ entidad: "contactos", filtros: [{ campo: "telefono", contiene: "555" }] });
await refused({ entidad: "contactos", filtros: [{ campo: "telefono", vacio: false }] });
await refused({ entidad: "contactos", orden: [{ campo: "telefono", dir: "asc" }] });
await refused({ entidad: "contactos", agrupar: [{ campo: "telefono" }] });

Even vacio: false ("is not empty") is refused, because "which contacts have a phone at all" is information too. There is one exception: equality against a pseudonym on a pseudonymized field. The model already holds that token, so matching on it reveals nothing new, and the server resolves the token to its value before the query runs.

Search gets the same treatment from the other side. Before the hybrid search runs, values of masked kinds are stripped out of the query string, and a hit whose record has a masked value that the query named is dropped. The consequence is intended: "find the customer with phone 600 ..." finds nothing by that phone.

A log that keeps ids, not values

The last stage writes ai_access_log: who asked, on which channel, in which organizations, with which tool, what was asked and which records came back. The records are ids by type, never copies. The arguments go through a redaction pass of their own before they are stored, which keeps the last characters of ID numbers and IBANs, half of emails and phones, and replaces card numbers, passwords and keys whole. Each tool call also records how many fields the privacy pass masked, and never which ones.

The log tables are append-only in Postgres, not by convention:

packages/db/drizzle/0041_ai_rls.sql
CREATE OR REPLACE FUNCTION ai_logs_guard() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
	IF TG_OP = 'UPDATE' THEN
		RAISE EXCEPTION '% is append-only: rows cannot be changed', TG_TABLE_NAME USING ERRCODE = 'check_violation';
	END IF;
	IF TG_OP = 'TRUNCATE' THEN
		RAISE EXCEPTION '% is append-only: it cannot be truncated', TG_TABLE_NAME USING ERRCODE = 'check_violation';
	END IF;
	-- DELETE: only the retention prune (ai_logs_prune), and only rows older than a week.
	IF current_setting('app.ai_logs_prune', true) IS DISTINCT FROM 'on' OR OLD.created_at > now() - interval '7 days' THEN
		RAISE EXCEPTION '% is append-only: rows are only removed by the retention prune', TG_TABLE_NAME USING ERRCODE = 'check_violation';
	END IF;
	RETURN OLD;
END $$;

A daily worker task prunes rows after 180 days. Owners and admins read the log in settings as sentences ("Read 1 invoice and 1 contact"), and the ids are resolved at the moment of viewing, with the viewer's own permissions. The log never needed to store the data to show it, so it can't leak it either.

What the model is told, and why

After all that, Suma's prompt still describes the policy. Every turn carries a block, built by describePrivacy, that says what it can't see, what it only sees the last digits of, what it sees as pseudonyms, and which entities it has no access to, followed by an instruction not to invent those values, not to ask the person to read them out, and not to try to work them out by filtering, sorting or searching.

That looks like the system-prompt sentence this post started by dismissing, and it is. The difference is its job. It doesn't enforce anything; the four stages below it do. It exists so the assistant behaves well around the gap: when someone asks for a customer's phone number, Suma says the organization has hidden it, instead of apologising vaguely or asking the person to dictate it. The settings page shows owners the same text, next to a live preview of how Suma sees a sample contact under the rules they are editing.

The test that ties it together, privacy.db.test.ts, seeds a contact in the development database, runs a simulated model through the real tools, and checks that none of the contact's masked values comes out through search, query, record reads, memories, chat cards, the memory profile, message recall, the context block or free text.

So the trade is this. The data stays real on the server: the tables, the search index and the embeddings hold actual values, so search works and actions run on real numbers. What changes is the copy that crosses into the model, and every copy crosses at one point, after the database has already decided which rows to return. The cost is a model that can't sort contacts by phone number, and can't count which customers have one. The organization chose that when it hid the field.

Related articles

A Non-Custodial Payment Gateway

A payment processor holds your money for a moment. Paybit deletes that moment: a non-custodial crypto gateway that splits every payment on-chain.

A URL That Speaks Markdown

This article is also a plain markdown file, a .md away: content negotiation that serves the same writing to people and to machines from one source.

A 3D Book in Pure CSS

Each Secondbook travel guide is a hardcover you can turn in your hand. No WebGL: twenty-five divs, a few CSS transforms and a light model run per pose.