{"id":47446,"date":"2026-10-03T15:22:11","date_gmt":"2026-10-03T13:22:11","guid":{"rendered":"https:\/\/www.dbi-services.com\/blog\/?p=47446"},"modified":"2026-10-03T15:22:13","modified_gmt":"2026-10-03T13:22:13","slug":"ai-agents-on-sensitive-data-what-postgresql-can-enforce-and-what-it-cant","status":"publish","type":"post","link":"https:\/\/www.dbi-services.com\/blog\/ai-agents-on-sensitive-data-what-postgresql-can-enforce-and-what-it-cant\/","title":{"rendered":"AI agents on sensitive data: what PostgreSQL can enforce, and what it can&#8217;t"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">Every useful database content, document behind an internal AI agent names someone, a company, server names,&#8230; <br>A relationship manager might ask &#8220;what did this client complain about last quarter?&#8221;, an operations engineer might aska &#8220;what happened on this server last month?&#8221;, and the answer sits in notes, emails and incident tickets, CRMs and ERPs data, full of client names, staff names, IBANs, hostnames and IP addresses: exactly the text you would rather not send to a model.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Usually I see two answers for that. Keep AI away from that data, which in practice means there is no project. Or redact before sending: replace every name with <code>[REDACTED]<\/code> and send the rest. That one looks safe and fails quietly, because once every client is <code>[REDACTED]<\/code>, the assistant can no longer tell one client from another because it is like joining two tables without a key. It answers, but without being relevant enough.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">I built the layer in between, on PostgreSQL: row-level security, labels, the token columns and audit live in the database, the tokenizing and the gate in the application, the key and the mapping on a second server. Here is what it does, on a question from the lab (condensed from the run&#8217;s output):<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\nquestion asked : What did Emmental Immobilien AG complain about regarding custody fees?\nquestion sent  : What did CLIENT_43aa5d8366b5 complain about regarding custody fees?\nmodel answer   : CLIENT_43aa5d8366b5 complained that the custody fees had increased without notice. &#x5B;doc 10]\nfor a reviewer : Emmental Immobilien AG complained that the custody fees had increased without notice. &#x5B;doc 10]\n\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">The model chose its own search, kept the token, answered correctly and cited its source. It never held the name. This is pseudonymization, not anonymization: the tokens are reversible by design (for everything downstream of the filter, only through a separate server), and the text around a token can still say a lot about someone. The goal is narrower and testable: no known sensitive value reaches the provider, and the answers stay as relevant as on raw text.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">In my lab I take the example of a bank, because in Switzerland, sending client data covered by banking secrecy to an external API is a disclosure question under Article 47 of the Banking Act before it is a quality question, and FINMA expects banks to manage the data risks and third-party dependencies that come with AI. So the question is: can we build something secure enough to still use agentic capabilities? If it works in a sector as regulated as banking, most organizations can use it too. Retrieval techniques make governance possible; the other question is how far they go.<br>The lab has three synthetic banks, 240 clients, 18 relationship managers, 481 accounts, 30 servers and 1,521 free-text documents. It is small on purpose: big enough to measure, small enough to rebuild on a laptop and read every row. Every name in it is generated; where a generated company name turned out to match a firm in the Swiss commercial register, the generator swaps it, and any remaining resemblance is coincidental. This post walks through the data model, the vault that holds the tokens, the layers between the data and the model, a deliberate labelling gap and what catches it, the relevance measured with and without filtering, what a tracing tool ends up storing, and the query plans underneath.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The interesting part is not hiding the names. It is knowing precisely which data is sensitive: the labels are what stop the leak, the same labels are what give the relevance back, and a name that no label knows is exactly where the guarantee ends.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This demonstrates:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Row-level security on the vectors, not only on the documents, with the tenant set per transaction.<\/li>\n\n\n\n<li>A label registry and a coverage query that fails toward a finding when a sensitive column is missing.<\/li>\n\n\n\n<li>Deterministic tokens computed with a keyed hash on a separate PostgreSQL server, which is the only way back for everything downstream of the filter.<\/li>\n\n\n\n<li>An egress gate that scans every request to the model, logs a hash of it, and blocks on a single finding.<\/li>\n\n\n\n<li>Relevance kept: with the same search and the same tools, answers on tokens are as good as answers on raw text.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Lab Environment Details<\/strong> <br><br><strong>Bank server: <\/strong><br>PostgreSQL 18.3, pgvector 0.8.6, pgAudit 18.0, postgresql_anonymizer 3.2.2. <br><strong>Vault server:<\/strong> <br>PostgreSQL 18.3 with pgcrypto. <br><strong>Embeddings computed locally on CPU: <\/strong><br>dense voyage-4-nano at 1,024 dimensions, sparse SPLADE (<code>prithivida\/Splade_PP_en_v1<\/code>) through fastembed. <br><strong>Agent<\/strong>: <br>OpenAI <code>gpt-6-luna<\/code> through Chat Completions with function tools. <br><strong>Tracing:<\/strong><br> Langfuse 4.50 self-hosted from its official Docker Compose file (ClickHouse 25.12, PostgreSQL 17, Redis 7, MinIO), Python SDK 4.16. All in Docker on WSL2, in a 6 GB VM. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The lab is public and available with a runbook that replays every output below: <a href=\"https:\/\/github.com\/boutaga\/pgvector_RAG_search_lab\/tree\/main\/lab\/16_deterministic_filtering\" target=\"_blank\" rel=\"noopener\">lab 16 in pgvector_RAG_search_lab<\/a>. <br>Building it takes about 40 minutes of embeddings on a CPU, and replaying every step about an hour and a half; the dry runs need no API key.<\/p>\n\n\n\n<h2 id=\"h-the-data-model\" class=\"wp-block-heading\">The data model<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Two servers, three schemas. The bank server holds the business data, its filtered copies and the governance tables. The vault server holds only the key and the token mapping. No foreign key, foreign server or <code>dblink<\/code> crosses between them.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"831\" src=\"https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/01-schema-bank-1024x831.png\" alt=\"\" class=\"wp-image-47449\" srcset=\"https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/01-schema-bank-1024x831.png 1024w, https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/01-schema-bank-300x243.png 300w, https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/01-schema-bank-1536x1246.png 1536w, https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/01-schema-bank-768x623.png 768w, https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/01-schema-bank-2048x1661.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><em>The bank schema: business data, its filtered copies, the mention index and the versioned embeddings. Each sensitive structured column sits next to its token column; the free text has redacted and tokenized copies (title copies omitted here).<\/em><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Each document is stored three times, raw, redacted and tokenized. That is not how you would run production; it is how you measure what each choice costs, on the same rows, with the same model:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: sql; title: ; notranslate\" title=\"\">\nCREATE TABLE bank.documents (\n    doc_id          int PRIMARY KEY,\n    bank_id         text NOT NULL REFERENCES bank.banks,\n    doc_type        text NOT NULL CHECK (doc_type IN (&#039;advisor_note&#039;, &#039;incident&#039;, &#039;email&#039;)),\n    title           text NOT NULL,\n    body            text NOT NULL,\n    created_at      timestamptz NOT NULL,\n    title_redacted  text,\n    body_redacted   text,\n    title_tokenized text,\n    body_tokenized  text,\n    key_version     int\n);\n\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">Embeddings are versioned: every row says which model, which text state and which vault key produced it. Why that matters, and the event-driven pipeline that keeps vectors current, is the subject of my <a href=\"https:\/\/www.dbi-services.com\/blog\/rag-series-embedding-versioning-with-pgvector-why-event-driven-architecture-is-a-precondition-to-ai-data-workflows\/\" target=\"_blank\" rel=\"noopener\">embedding versioning post<\/a> and <a href=\"https:\/\/www.dbi-services.com\/blog\/rag-series-embedding-versioning-lab\/\" target=\"_blank\" rel=\"noopener\">its lab<\/a>. A partial unique index allows at most one active version per state, so a new set can be built next to the old one and switched when it is ready:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: sql; title: ; notranslate\" title=\"\">\nCREATE UNIQUE INDEX embedding_versions_one_active ON bank.embedding_versions (state) WHERE is_active;\n\n-- shortened: the repository version adds a hash of the embedded text\nCREATE TABLE bank.embeddings (\n    version_id int  NOT NULL REFERENCES bank.embedding_versions,\n    doc_id     int  NOT NULL REFERENCES bank.documents,\n    bank_id    text NOT NULL,\n    dense      vector(1024) NOT NULL,\n    sparse     sparsevec(30522) NOT NULL,\n    PRIMARY KEY (version_id, doc_id)\n);\nCREATE INDEX embeddings_dense_hnsw ON bank.embeddings USING hnsw (dense vector_cosine_ops);\nCREATE INDEX ON bank.embeddings (version_id, bank_id);\n\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">The <code>bank_id<\/code> on <code>embeddings<\/code> is deliberate. The vector table is the one people forget to protect: it gets the same tenant column, and the same row-level security policy, as the documents.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The governance schema <code>gov<\/code> is plain tables. A registry says which column is sensitive and where its token goes, and an egress log records every request that leaves, with a hash and the size of the payload and what the scanner found, never the payload itself. The gateway may read the log, insert into it and set each request&#8217;s outcome, nothing else:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: sql; title: ; notranslate\" title=\"\">\nCREATE TABLE gov.sensitive_columns (\n    table_name   text NOT NULL,\n    column_name  text NOT NULL,\n    category     text NOT NULL,   -- CLIENT, PERSON, EMAIL, IBAN, HOST, IP, FREE_TEXT\n    token_column text,            -- where the deterministic token is written\n    PRIMARY KEY (table_name, column_name)\n);\n\nGRANT SELECT, INSERT ON gov.egress_log TO gateway;\nGRANT UPDATE (outcome) ON gov.egress_log TO gateway;   -- the rest of the log is append-only\n\n<\/pre><\/div>\n\n\n<h2 id=\"h-the-vault-which-everything-else-depends-on\" class=\"wp-block-heading\">The vault, which everything else depends on<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The key, and the mapping from a token back to its value, are what turn a token into a name. Anyone who only ever sees tokens (the model, the agent&#8217;s role, a tracing store) has no other way back, and a reviewer gets names only through it, so they live on a second PostgreSQL server, with its own roles and credentials, and no <code>postgres_fdw<\/code> or <code>dblink<\/code> on the bank side. The two containers also sit on separate Docker networks. On one laptop that is as far as it goes: the bank&#8217;s superuser can run programs on its server, and the vault&#8217;s port is still reachable through the Docker host. In production the vault belongs on its own host, behind a firewall that only lets the gateway in.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">One honest limit of this lab&#8217;s layout: the bank tables keep each token next to its raw value (<code>client_token<\/code> beside <code>client_name<\/code>), because the pipeline needs both to filter the text. So the pipeline role and the bank&#8217;s administrators can map tokens back without the vault; what keeps the agent out is column privileges, below. One more route stays open in the lab: the agent&#8217;s role can read the raw-text embeddings kept for the measurement, and a sparse vector is indexed by words, so it partly exposes the words it was built from, names included. A stricter design moves the token columns out of the raw tables.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"766\" src=\"https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/03-schema-vault-1024x766.png\" alt=\"\" class=\"wp-image-47451\" srcset=\"https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/03-schema-vault-1024x766.png 1024w, https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/03-schema-vault-300x224.png 300w, https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/03-schema-vault-767x574.png 767w, https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/03-schema-vault-1536x1149.png 1536w, https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/03-schema-vault-2048x1532.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><em>The vault server holds only the key and the token mapping. <code>tokenizer<\/code> can compute tokens but read neither table; <code>reidentifier<\/code> reads the mapping, never the key (the vault&#8217;s admin, a superuser in the lab, reads both).<\/em><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Nobody reads the key, not even the role that creates tokens. Tokens are computed inside the vault by a <code>SECURITY DEFINER<\/code> function, which runs with its owner&#8217;s rights (in the lab the owner is the vault&#8217;s admin, a superuser, which a production setup would replace with a dedicated non-superuser owner), with a pinned <code>search_path<\/code>:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: sql; title: ; notranslate\" title=\"\">\n-- shortened: the repository version also raises when no key is active\nCREATE FUNCTION vault.tokenize(p_category text, p_values text&#x5B;])\nRETURNS TABLE (value text, token text)\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = vault, public, pg_temp\nAS $$\n#variable_conflict use_column\nDECLARE\n    k  bytea;\n    kv int;\nBEGIN\n    SELECT secret, key_version INTO k, kv FROM vault.keys WHERE active;\n    RETURN QUERY\n    WITH src AS (\n        SELECT DISTINCT v AS value FROM unnest(p_values) AS v WHERE v IS NOT NULL\n    ), tok AS (\n        SELECT s.value,\n               upper(p_category) || &#039;_&#039; ||\n               substr(encode(hmac(convert_to(upper(p_category) || &#039;:&#039; || lower(btrim(s.value)), &#039;UTF8&#039;),\n                                  k, &#039;sha256&#039;), &#039;hex&#039;), 1, 12) AS token\n        FROM src s\n    ), ins AS (\n        INSERT INTO vault.mapping (token, key_version, category, value)\n        SELECT DISTINCT ON (t.token) t.token, kv, upper(p_category), t.value\n        FROM tok t ORDER BY t.token, t.value COLLATE &quot;C&quot;\n        ON CONFLICT DO NOTHING\n    )\n    SELECT t.value, t.token FROM tok t;\nEND;\n$$;\n\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">Functions are executable by <code>PUBLIC<\/code> by default, so the vault script revokes that before granting anything, and the <code>public<\/code> schema in that <code>search_path<\/code> is not writable by either role (the default since PostgreSQL 15, and revoked explicitly in the script):<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: sql; title: ; notranslate\" title=\"\">\nREVOKE ALL ON ALL TABLES    IN SCHEMA vault FROM PUBLIC;\nREVOKE ALL ON ALL FUNCTIONS IN SCHEMA vault FROM PUBLIC;\nGRANT USAGE ON SCHEMA vault TO tokenizer, reidentifier;\nGRANT EXECUTE ON FUNCTION vault.tokenize(text, text&#x5B;]), vault.active_key_version() TO tokenizer;\nGRANT SELECT ON vault.mapping TO reidentifier;\nGRANT EXECUTE ON FUNCTION vault.active_key_version() TO reidentifier;\n\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">From the run, by role:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: sql; title: ; notranslate\" title=\"\">\ntokenizer:    SELECT * FROM vault.keys;\n              ERROR:  permission denied for table keys\ntokenizer:    SELECT * FROM vault.tokenize(&#039;client&#039;, ARRAY&#x5B;&#039;Brenta Kontor AG&#039;, &#039;brenta kontor ag &#039;]);\n                     value       |        token\n              -------------------+---------------------\n               brenta kontor ag  | CLIENT_7119873645a3\n               Brenta Kontor AG  | CLIENT_7119873645a3\nreidentifier: SELECT * FROM vault.keys;\n              ERROR:  permission denied for table keys\n\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">Three things the token is, and one it is not. It is the category plus the first 12 hex characters of an HMAC-SHA256 of the normalized value, a keyed hash nobody can reproduce without the key; whoever holds the tokenizer credential can still submit a guess and compare tokens (and the guess lands in the mapping), which is why that credential stays with the gateway. It is 48 bits, so by the birthday bound a collision within one category becomes a 1% risk around 2.4 million values; the largest category here has 481. And this version does not detect a collision: <code>ON CONFLICT DO NOTHING<\/code> would skip it silently, where a production version should raise.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">What it is not is entity resolution. The hash input is category and normalized string, with no tenant and no entity ID, so two different clients with the same name would share a token, and the same name in two banks gets the same token. The synthetic data has no such pair, which is why the results below are not affected, but a real CRM has them. A production version would tokenize a tenant-scoped canonical ID, and map aliases to it explicitly. The lab does that in one place only: a server&#8217;s hostname and its full domain name share one token.<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\"> <code>   hostname    |             fqdn              |    host_token     |    fqdn_token\n----------------+-------------------------------+-------------------+-------------------\n alb-pg-prd-01  | alb-pg-prd-01.alder.internal  | HOST_f774b8916acd | HOST_f774b8916acd\n<\/code><\/pre>\n\n\n\n<h2 id=\"h-architecture-two-servers-one-way-in\" class=\"wp-block-heading\">Architecture: two servers, one way in<\/h2>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Layer<\/th><th>What it answers<\/th><th>PostgreSQL doing the work<\/th><\/tr><\/thead><tbody><tr><td>1. Row-level security<\/td><td>Which bank&#8217;s rows a session sees<\/td><td><code>CREATE POLICY<\/code> on every business table, embeddings included<\/td><\/tr><tr><td>2. Labelling<\/td><td>What is sensitive, and where the gaps are<\/td><td>A registry, <code>SECURITY LABEL FOR anon<\/code>, a coverage view<\/td><\/tr><tr><td>3. Deterministic tokens<\/td><td>Same value, same token, in documents and questions<\/td><td><code>hmac()<\/code> in the vault, token columns, a mention index<\/td><\/tr><tr><td>4. Egress gate<\/td><td>What left, proved by a scan of every request<\/td><td>A gateway function in Python, logging to <code>gov.egress_log<\/code><\/td><\/tr><tr><td>5. Vault<\/td><td>Who may turn tokens back into values<\/td><td>A second server, <code>SECURITY DEFINER<\/code>, revoked defaults<\/td><\/tr><tr><td>6. Audit<\/td><td>What the agent&#8217;s role executed<\/td><td>pgAudit session logging on <code>app_agent<\/code><\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"379\" src=\"https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/06-governance-layers-1024x379.png\" alt=\"\" class=\"wp-image-47453\" srcset=\"https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/06-governance-layers-1024x379.png 1024w, https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/06-governance-layers-768x284.png 768w, https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/06-governance-layers-300x111.png 300w, https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/06-governance-layers-1536x568.png 1536w, https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/06-governance-layers-2048x758.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><em>Where each layer sits: four on the bank server, the gate in the gateway process, the key and mapping on the vault server. The dotted paths go to the vault: tokens computed once and written back to the bank, and re-identification, used only for a reviewer.<\/em><\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"725\" src=\"https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/05-question-sequence-1024x725.png\" alt=\"\" class=\"wp-image-47454\" srcset=\"https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/05-question-sequence-1024x725.png 1024w, https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/05-question-sequence-767x543.png 767w, https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/05-question-sequence-300x212.png 300w, https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/05-question-sequence-1536x1087.png 1536w, https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/05-question-sequence-2048x1450.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><em>One question end to end: the name never leaves the gateway and the model sees tokens only. The last two steps run only when a reviewer needs the real name; otherwise the answer stays in tokens.<\/em><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The agent never writes SQL. It gets three tools, <code>search_documents<\/code>, <code>client_profile<\/code> and <code>host_documents<\/code>, each a plain <code>SECURITY INVOKER<\/code> function that runs as <code>app_agent<\/code>, so row-level security and column privileges apply inside it. In the terms of my <a href=\"https:\/\/www.dbi-services.com\/blog\/rag-mcp-skills-three-paradigms-for-llms-talking-to-your-database-and-why-governance-changes-everything\/\" target=\"_blank\" rel=\"noopener\">RAG, MCP, Skills post<\/a>, this is an agent with tool access, but every tool is a retrieval function under the database&#8217;s rules, so the DBA keeps the control, MCP and skills still being weaker on the governance aspects. <code>app_agent<\/code> has no privilege on any raw sensitive column (it reads tokens and attributes that do not identify anyone on their own, such as currency, domicile or balance), so a tool cannot return a client&#8217;s name even by mistake. Those attributes do reach the provider, tied to a token, which is part of why this is pseudonymization. (It can read the raw-text embeddings, which exist here only for the measurement; a production setup would not keep them.)<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: sql; title: ; notranslate\" title=\"\">\nSELECT has_column_privilege(&#039;app_agent&#039;, &#039;bank.clients&#039;, &#039;client_name&#039;,  &#039;SELECT&#039;) AS agent_reads_client_name,\n       has_column_privilege(&#039;app_agent&#039;, &#039;bank.clients&#039;, &#039;client_token&#039;, &#039;SELECT&#039;) AS agent_reads_client_token;\n\n agent_reads_client_name | agent_reads_client_token\n-------------------------+--------------------------\n f                       | t\n\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">Where the trust sits matters as much as the layers. The guarantee holds for what the agent can reach through those tools and that role. The gateway is the trusted part: it chooses the tenant, it holds the tokenizer and reidentifier credentials, and it decides what goes to the provider. It must derive the tenant from the authenticated user, never from an argument the model wrote, because row-level security enforces whatever bank the session declares without asking whether the session was entitled to declare it. A compromised gateway is outside what this design protects.<\/p>\n\n\n\n<h2 id=\"h-what-postgresql-enforces-what-it-doesn-t-and-why-the-ecosystem-matters\" class=\"wp-block-heading\">What PostgreSQL enforces, what it doesn&#8217;t, and why the ecosystem matters<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The database enforces these by itself, on every query, whatever the application does: row-level security on the documents, the mention index and the vectors; column privileges that keep raw values away from the agent&#8217;s role; the revoked defaults and the <code>SECURITY DEFINER<\/code> function that keep the key unreadable in the vault; and pgAudit&#8217;s record of what the agent executed. It also holds the label catalog and reports what is missing from it through the coverage view, though a report only helps when someone reads it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">It does not do the rest, and I won&#8217;t pretend it does. The gate that scans every outbound request is a short Python function in the gateway. The tenant is chosen by the application. The labels are only as complete as the people who maintain them, and a name that is in no table passes. The tracing store and the model provider sit outside the database. And the vault is isolated by separate servers and networks (in the lab, separate containers on one host), not by SQL.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So why build it on PostgreSQL rather than around a separate vector store? Three reasons. First, one access model: the vectors live in the same engine as the rows they came from, under the same policy and the same privileges, so there is no second copy of every rule to keep in sync. Second, every database-side layer is core PostgreSQL or an extension, not a new product: pgvector for dense and sparse vectors, postgresql_anonymizer for the labels, pgcrypto for the keyed hash, pgAudit for the audit, all under the PostgreSQL License, maintained in the open and readable by the people who have to trust them. Third, everything except the chat model runs on your own machines: the two PostgreSQL servers, the embedding models (Apache 2.0, on CPU), and even the observability, since Langfuse (MIT, apart from its enterprise edition, the one its documentation lists retention under) runs on PostgreSQL and ClickHouse (Apache 2.0), plus a queue and an object store with their own licences. At run time the only external call in the lab is the chat model, and it is the call the gate scans, and in the governed setup blocks. For a bank that has to manage the third-party dependencies FINMA points to, that is what sovereignty means here: the model can move on premises without changing the data, the labels, the vault or the gate.<\/p>\n\n\n\n<h2 id=\"h-one-design-choice-entity-first-then-meaning\" class=\"wp-block-heading\">One design choice: entity first, then meaning<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A hex string like <code>CLIENT_43aa5d8366b5<\/code> means nothing to a semantic model, so dense search barely uses it. But the labels give me something better than semantics: while filtering the text, the tokenizer records which document mentions which entity, in <code>bank.document_mentions<\/code>. When a question names an entity, the search starts with the documents that mention it, ranks those by meaning, then adds the rest:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: sql; title: ; notranslate\" title=\"\">\n-- excerpt of bank.retrieve()\nent AS (\n    -- documents mentioning every entity the question names\n    SELECT m.doc_id FROM bank.document_mentions m\n    WHERE p_method = &#039;entity&#039; AND cardinality(p_tokens) &gt; 0 AND m.token = ANY(p_tokens)\n    GROUP BY m.doc_id HAVING count(DISTINCT m.token) = cardinality(p_tokens)\n), ent_scored AS (\n    SELECT e.doc_id,\n           1.0 + 1.0 \/ (60 + row_number() OVER (ORDER BY e.dense &lt;=&gt; p_dense))\n               + 1.0 \/ (60 + row_number() OVER (ORDER BY e.sparse &lt;#&gt; p_sparse)) AS score\n    FROM bank.embeddings e JOIN v USING (version_id)\n    WHERE e.doc_id IN (SELECT doc_id FROM ent)\n)\n\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">Inside the entity&#8217;s documents the order is reciprocal rank fusion of dense and sparse, the same as everywhere else. The <code>1.0 +<\/code> puts them ahead of everything that does not mention the entity. It is a choice, not a constant: if your questions often name an entity only in passing, you would add a boost instead of ranking first. The mention index is a plain table with a B-tree on <code>token<\/code>, under the same row-level security as the rest. Raw text can use it too, which matters for a fair comparison below. Run on its own, as <code>app_agent<\/code> with <code>bank_a<\/code> set (inside the function it is a CTE, and the function&#8217;s own plan is a single opaque function scan):<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: sql; title: ; notranslate\" title=\"\">\nEXPLAIN (ANALYZE, BUFFERS, COSTS OFF)\nSELECT m.doc_id FROM bank.document_mentions m\nWHERE m.token = ANY(ARRAY&#x5B;&#039;CLIENT_43aa5d8366b5&#039;])\nGROUP BY m.doc_id HAVING count(DISTINCT m.token) = 1;\n\n<\/pre><\/div>\n\n\n<pre class=\"wp-block-preformatted\"> <code>GroupAggregate (actual time=0.072..0.075 rows=6.00 loops=1)\n   Group Key: doc_id\n   Filter: (count(DISTINCT token) = 1)\n   -&gt;  Sort (actual time=0.066..0.067 rows=6.00 loops=1)\n         -&gt;  Bitmap Heap Scan on document_mentions m (actual time=0.042..0.044 rows=6.00 loops=1)\n               Recheck Cond: (token = ANY ('{CLIENT_43aa5d8366b5}'::text[]))\n               Filter: (bank_id = current_setting('app.bank_id'::text, true))\n               -&gt;  Bitmap Index Scan on document_mentions_token_idx (actual time=0.025..0.025 rows=6.00 loops=1)\n                     Index Cond: (token = ANY ('{CLIENT_43aa5d8366b5}'::text[]))\n Execution Time: 0.185 ms\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">(Shortened.) The token lookup is one index probe and six rows. Note where the row-level security predicate lands: here it is a <code>Filter<\/code> after the lookup, because the B-tree is on <code>token<\/code> alone. Keep that in mind for the vector plan below.<\/p>\n\n\n\n<h2 id=\"h-row-level-security-on-the-vectors\" class=\"wp-block-heading\">Row-level security on the vectors<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Every business table that carries <code>bank_id<\/code> gets the same policy, the vector table included:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: sql; title: ; notranslate\" title=\"\">\nALTER TABLE bank.embeddings ENABLE ROW LEVEL SECURITY;\nCREATE POLICY tenant_isolation ON bank.embeddings\n    USING (bank_id = current_setting(&#039;app.bank_id&#039;, true));\n\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">The bank is set per transaction with <code>set_config(..., true)<\/code>, the function form of <code>SET LOCAL<\/code>, so a transaction-mode pooler cannot hand one bank&#8217;s setting to the next request. As <code>app_agent<\/code>:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: sql; title: ; notranslate\" title=\"\">\nbank=&gt; SELECT count(*) AS documents_without_tenant FROM bank.documents;\n documents_without_tenant\n--------------------------\n                        0\n\nbank=&gt; BEGIN;\nbank=&gt; SELECT set_config(&#039;app.bank_id&#039;, &#039;bank_a&#039;, true);\nbank=&gt; SELECT bank_id, count(*) AS documents FROM bank.documents GROUP BY bank_id;\n bank_id | documents\n---------+-----------\n bank_a  |       508\nbank=&gt; COMMIT;\n\nbank=&gt; SELECT count(*) AS documents_after_commit FROM bank.documents;\n documents_after_commit\n------------------------\n                      0\n\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">Be precise about why both counts are zero, because they are not the same case. On PostgreSQL 18.3, <code>current_setting('app.bank_id', true)<\/code> returns NULL when the setting was never defined in the session, and an empty string once an earlier transaction has set it locally: the custom setting now exists, with an empty value. Neither matches a <code>bank_id<\/code>, so a session that forgets its bank gets nothing. If you would rather have one case, <code>NULLIF(current_setting('app.bank_id', true), '')<\/code> in the policy makes both NULL. And a transaction-local value only overrides: if something set the parameter at session level earlier, that value is back after the commit, which is why the pooling argument assumes connections that never set it at session level.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The raw client name is refused before row-level security even matters: <code>SELECT client_name FROM bank.clients<\/code> as <code>app_agent<\/code> fails with <code>permission denied<\/code>. Neither role owns the tables; if your application connects as the table owner, add <code>FORCE ROW LEVEL SECURITY<\/code>, or the owner sees every row. The pipeline role, <code>gateway<\/code>, has <code>BYPASSRLS<\/code> because it serves all three banks, and the agent never gets near it.<\/p>\n\n\n\n<h2 id=\"h-labelling-the-gap-and-what-caught-it\" class=\"wp-block-heading\">Labelling, the gap, and what caught it<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The registry says what is sensitive; a procedure turns it into security labels, with postgresql_anonymizer as the label provider, so the catalog (<code>pg_seclabels<\/code>) is the one place to ask whether a column is labelled. The coverage view asks it for every text column of the business tables:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: sql; title: ; notranslate\" title=\"\">\n-- shortened: the repository version also skips three technical tables and the\n-- derived columns (tokens, filtered copies, text hashes)\nCREATE VIEW gov.label_coverage AS\nSELECT c.table_name, c.column_name, sl.label, sc.category,\n       (sl.label IS NOT NULL) AS labelled\nFROM information_schema.columns c\nLEFT JOIN pg_seclabels sl\n       ON sl.provider = &#039;anon&#039; AND sl.objtype = &#039;column&#039;\n      AND sl.objoid = format(&#039;bank.%I&#039;, c.table_name)::regclass\n      AND sl.objsubid = c.ordinal_position\nLEFT JOIN gov.sensitive_columns sc\n       ON sc.table_name = c.table_name AND sc.column_name = c.column_name\nWHERE c.table_schema = &#039;bank&#039; AND c.data_type = &#039;text&#039;\n  AND c.column_name NOT IN (&#039;bank_id&#039;, &#039;doc_type&#039;, &#039;client_type&#039;, &#039;currency&#039;,\n                            &#039;role&#039;, &#039;environment&#039;, &#039;domicile&#039;, &#039;category&#039;, &#039;token&#039;);\n\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">The choice that matters is the direction of the list: it excludes what is known to be harmless, so a new text column shows up as a gap until someone labels it or excludes it on purpose. It fails toward a finding, not toward silence. It only sees text columns; a sensitive value stored as <code>inet<\/code>, <code>date<\/code> or <code>jsonb<\/code> needs its own rule. I left one sensitive column out on purpose, the clients&#8217; phone numbers:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: sql; title: ; notranslate\" title=\"\">\nbank=# SELECT table_name, column_name, category, labelled FROM gov.label_coverage ORDER BY labelled, table_name;\n      table_name       |  column_name  | category  | labelled\n-----------------------+---------------+-----------+----------\n clients               | contact_phone |           | f\n accounts              | iban          | IBAN      | t\n clients               | client_name   | CLIENT    | t\n ...\n(10 rows)\n\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">Tokenizing then replaces every labelled value in the free text, using a dictionary of every value the labelled columns hold, matched as whole words ignoring case, longest first, with the grouped spelling of each IBAN added. One caveat before the numbers: in this lab the dictionary and the scanner read hand-written lists of columns that match the registry, rather than reading the registry itself. A production version should derive both from it.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"386\" height=\"1024\" src=\"https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/04-build-flow-386x1024.png\" alt=\"\" class=\"wp-image-47455\" srcset=\"https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/04-build-flow-386x1024.png 386w, https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/04-build-flow-113x300.png 113w, https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/04-build-flow-768x2036.png 768w, https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/04-build-flow-579x1536.png 579w, https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/04-build-flow-772x2048.png 772w, https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/04-build-flow-scaled.png 965w\" sizes=\"auto, (max-width: 386px) 100vw, 386px\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><em>Building the governed corpus: the registry drives the labels and the coverage check, the vault computes the tokens, and one dictionary pass filters the text, fills the mention index and feeds the local embedding models.<\/em><\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: bash; title: ; notranslate\" title=\"\">\n$ python python\/scan_corpus.py\nraw         7171 values in 1521 documents: CLIENT 3024, PERSON 1833, EMAIL 960, HOST 630, IBAN 480, PHONE 154, IP 90\nredacted     154 values in  154 documents: PHONE 154\ntokenized    154 values in  154 documents: PHONE 154\n\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">The raw corpus carries 7,171 detected occurrences of sensitive values. After tokenization what is left is exactly the gap: 154 phone numbers from the column nobody labelled. Now the governed agent, asked about a client whose note carries that phone number, in a dry run that builds the real request and stops before it leaves:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: bash; title: ; notranslate\" title=\"\">\n$ python python\/agent.py --dry-run --bank bank_a \\\n    &quot;What did Emmental Immobilien AG complain about regarding custody fees?&quot;\nquestion sent : What did CLIENT_43aa5d8366b5 complain about regarding custody fees?\n  &#x5B;tool] search_documents returned:\n    doc 10 advisor_note: PERSON_65a94b3e0336 met CLIENT_43aa5d8366b5. The client complained that the custody fees rose without notice. Next step agreed with CLIENT_43aa5d8366b\n    doc 12 advisor_note: PERSON_65a94b3e0336 met CLIENT_43aa5d8366b5. The client complained that the custody fees rose without notice. Next step agreed with CLIENT_43aa5d8366b\n    doc 11 email: From EMAIL_288e9f2f11a1. Dear client, following our call, CLIENT_43aa5d8366b5 complained that the custody fees rose without notice. Please confirm the\n    doc 14 email: From EMAIL_288e9f2f11a1. Dear client, following our call, CLIENT_43aa5d8366b5 requested an ESG screening of current equity holdings. Please confirm th\n    doc 13 advisor_note: PERSON_65a94b3e0336 met CLIENT_43aa5d8366b5. The client requested an ESG screening of current equity holdings. Next step agreed with CLIENT_43aa5d8366\n  &#x5B;egress] 3729 chars, filtering=tokenized, scanner hits=1 &#x5B;&#039;PHONE&#039;]  BLOCKED\n  stopped at the egress gate: 1 sensitive value(s) in outbound payload: &#x5B;&#039;PHONE&#039;]\n\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">Every retrieved document is the right client and every name, email and account is a token, but doc 12 still carries the phone number. The gate stopped the request: that is what prevented the transmission. It caught the number because the scanner&#8217;s own list includes the phone column (I left it out of the registry on purpose, so I knew), and a Swiss phone-number pattern would have matched it anyway. Then the label fixed the filtering:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: bash; title: ; notranslate\" title=\"\">\nbank=# INSERT INTO gov.sensitive_columns VALUES (&#039;clients&#039;, &#039;contact_phone&#039;, &#039;PHONE&#039;, &#039;phone_token&#039;);\nbank=# CALL gov.apply_labels();\n$ python python\/tokenize_corpus.py\n...\n$ python python\/agent.py --dry-run --bank bank_a &quot;What did Emmental Immobilien AG complain about regarding custody fees?&quot;\n  ... (same question sent, same five documents)\n  &#x5B;egress] 3731 chars, filtering=tokenized, scanner hits=0\n  payload cleared the gate (dry run: not sent)\n\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">Same documents, two characters longer (a 16-character phone number became an 18-character token), zero detected values. The gate stops what it recognizes; the label makes the filter remove it before it ever gets there. Both are needed, and neither knows a name that is in no table: nine documents each name a lawyer who exists in no column (Hugo Vogel in one, Selina Python in another), and those nine pass. Deterministic filtering is exactly as good as your labelling. Named-entity recognition can be added as a second net, but it puts a probabilistic step back into the path of a guarantee; I would rather know the ceiling and work on coverage.<\/p>\n\n\n\n<h2 id=\"h-did-filtering-cost-relevance\" class=\"wp-block-heading\">Did filtering cost relevance?<\/h2>\n\n\n\n<h3 id=\"h-retrieval\" class=\"wp-block-heading\">Retrieval<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">36 labelled questions, 30 naming a client or a server and 6 generic, run against the raw, redacted and tokenized embeddings as <code>app_agent<\/code> with its bank set:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: bash; title: ; notranslate\" title=\"\">\n$ python python\/measure.py\nstate      method  set       recall@5  nDCG@5  recall@10  nDCG@10\nraw        hybrid  entity       0.956   0.953      1.000    0.973\nraw        entity  entity       1.000   0.991      1.000    0.991\nredacted   hybrid  entity       0.192   0.185      0.408    0.284\ntokenized  dense   entity       0.431   0.447      0.625    0.541\ntokenized  sparse  entity       0.822   0.827      0.897    0.860\ntokenized  hybrid  entity       0.650   0.670      0.775    0.731\ntokenized  entity  entity       1.000   0.990      1.000    0.990\n\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">(Shortened; the six generic questions score 1.000 everywhere, which says they are too easy. <code>measure.py<\/code> prints the full table.) Redaction destroys entity questions, 0.192, because the documents no longer say who. Opaque tokens alone recover part of it: the dense model barely uses a hex string (0.431), the sparse model does (0.822). Entity-aware search over the mention index brings tokenized retrieval to 1.000, the same as raw with the same search. Redaction cannot use it: a redacted question names no one.<\/p>\n\n\n\n<h3 id=\"h-answers\" class=\"wp-block-heading\">Answers<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Retrieval is not what a relationship manager reads, so the model answered all 36 questions, citing documents as <code>[doc N]<\/code>, with deterministic scoring and no model judging another model. To isolate the cost of the tokens, the two setups compared here differ in the text and almost nothing else: both use the same entity-aware search, under the same row-level security and candidate limits, and the same single search tool. One reads raw text, the other tokens. (Two small differences remain: the entity is found by its token on one side and by its name on the other, and the raw side&#8217;s requests are logged but never blocked by the gate; none was blocked on either side in this run.) Both ran together on 3 October:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: bash; title: ; notranslate\" title=\"\">\n$ python python\/evaluate_answers.py --label fair2 --modes raw_entity,tokenized_search\nmode       set      cited rec cited prec context rec expl. entity blocked\nraw_entity entity       0.722      0.989       1.000          1.0       0\ntokenized_search entity       0.719      0.993       1.000          1.0       0\negress: {&#039;tokenized_search&#039;: {&#039;sent&#039;: 74, &#039;values_sent&#039;: 0, ...}, &#039;raw_entity&#039;: {&#039;sent&#039;: 73, &#039;values_sent&#039;: 871, ...}}\n\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">(Shortened to the entity questions; the generic ones score 1.000 in both.) Both setups had every expected document in front of the model (context recall 1.000), and both cited the same share of them, 0.722 against 0.719. With the same search, the tokens cost nothing measurable. What changed is what left: 871 detected occurrences of sensitive values for the raw setup, none for the tokenized one.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For reference, the naive setup (raw text, plain hybrid search across all three banks, filtered to one bank afterwards) put fewer expected documents in front of the model, context recall 0.981, and sent 1,134 detected occurrences in 75 requests, in a separate run the same day. The naive setup differs from the entity-aware ones in two ways at once, the search method and searching all banks before filtering, so these runs cannot say which one accounts for the gap; what it does show is that the tokens are not the cost.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Cited recall counts expected documents cited, which is document coverage, not answer completeness: when three notes repeat the same complaint, citing one of them can be a complete answer. So the honest claim is narrower than &#8220;better answers&#8221;: on this corpus, filtering keeps the answers grounded in the right documents while nothing known leaves.<\/p>\n\n\n\n<h2 id=\"h-what-a-tracing-tool-keeps\" class=\"wp-block-heading\">What a tracing tool keeps<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The egress log tells me what left and how big it was, and pgAudit tells me what <code>app_agent<\/code> executed. Neither tells the story of one question: what was asked, which documents came back, under which role, what the model answered, and whether it was right. So I traced every question with Langfuse, the open-source tracing and evaluation tool for LLM applications that <a href=\"https:\/\/clickhouse.com\/blog\/clickhouse-acquires-langfuse-open-source-llm-observability\" target=\"_blank\" rel=\"noopener\">ClickHouse acquired in January 2026<\/a>, self-hosted next to the lab. Look first at what it runs on: PostgreSQL for its transactional data, ClickHouse for traces and scores, Redis as a queue, S3-compatible storage for the raw events. AI observability is a database workload.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"459\" src=\"https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/Screenshot-2026-10-02-221406-1024x459.png\" alt=\"\" class=\"wp-image-47456\" srcset=\"https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/Screenshot-2026-10-02-221406-1024x459.png 1024w, https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/Screenshot-2026-10-02-221406-300x134.png 300w, https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/Screenshot-2026-10-02-221406-768x344.png 768w, https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/Screenshot-2026-10-02-221406-1536x688.png 1536w, https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/Screenshot-2026-10-02-221406-2048x918.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><em>Langfuse 4.50, self-hosted: one trace per question, marked <code>off<\/code> for the naive setup and <code>tokenized<\/code> for the governed one in the metadata. The naive rows show client names in the input and output columns; the governed rows show tokens.<\/em><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Each retrieval step records the database role, whether that role bypasses row-level security, and the document IDs, so the role question becomes SQL on the ClickHouse table that holds the spans, here on the traced run of 2 October:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: bash; title: ; notranslate\" title=\"\">\nclickhouse&gt; SELECT tags&#x5B;1] AS mode,\n                   metadata_values&#x5B;indexOf(metadata_names, &#039;db_role&#039;)]    AS db_role,\n                   metadata_values&#x5B;indexOf(metadata_names, &#039;bypass_rls&#039;)] AS bypass_rls,\n                   metadata_values&#x5B;indexOf(metadata_names, &#039;doc_ids&#039;)]    AS doc_ids\n            FROM events_full FINAL\n            WHERE session_id = &#039;langfuse&#039; AND name = &#039;search_documents&#039; AND input LIKE &#039;%succession%&#039;\n            ORDER BY mode, start_time LIMIT 1 BY mode;\n   \u250c\u2500mode\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500db_role\u2500\u2500\u2500\u252c\u2500bypass_rls\u2500\u252c\u2500doc_ids\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n1. \u2502 off       \u2502 gateway   \u2502 true       \u2502 &#x5B;18, 19, 206, 100, 22] \u2502\n2. \u2502 tokenized \u2502 app_agent \u2502 false      \u2502 &#x5B;18, 19, 16, 17, 292]  \u2502\n   \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">The naive search ran as a role that bypasses row-level security and stayed inside its bank only because the application&#8217;s query filtered on it. And because the evaluation&#8217;s scores sit next to the spans, a quality question becomes a join: for the governed answers that cited fewer expected documents, what had the search returned? For one of the two worst in that run, a question about replication lag on a server, the expected documents were 948 to 951; the search returned all four at the top, and the model cited one. That is not a retrieval problem, and without the trace next to the score I would have gone tuning the index.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Then I pointed the egress scanner at the trace store itself, for the three-setup run of 3 October:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: bash; title: ; notranslate\" title=\"\">\n$ python python\/scan_traces.py --label fair\nClickHouse, table events_full, session fair\n  mode       traces  spans  spans with values  sensitive values\n  off            36    150                144              2336\n  raw_entity     36    144                138              1850\n  tokenized      36    170                  0                 0\nobject storage, bucket langfuse\/events\/otel, session fair\n  (same counts, shortened)\n\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">Tracing records what the application sends and receives; that is its job. On the naive path the trace store ends up with 2,336 detected occurrences for questions that sent 1,134 to the provider: every retrieved document is stored once more as the search result, on top of the copies inside each model call, plus the questions and answers. And all of it twice, in ClickHouse and in object storage. On the governed path both hold zero detected values, and the reason is precise: the search result is recorded when the tool returns, before the gate scans the next request, so the traces are clean only because the tools return tokens. While the phone gap was open, doc 12&#8217;s number would have reached the trace store even though the gate blocked the request.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"968\" src=\"https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/image-16-1024x968.png\" alt=\"\" class=\"wp-image-47457\" srcset=\"https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/image-16-1024x968.png 1024w, https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/image-16-300x283.png 300w, https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/image-16-767x725.png 767w, https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/image-16.png 1270w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><em>The naive setup, as Langfuse stored it (2 October): the search step&#8217;s output holds the raw text, with the client&#8217;s name and the advisor&#8217;s name and email in clear, and an IBAN and a phone number too, masked here for publication.<\/em><\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"969\" src=\"https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/Screenshot-2026-10-02-221624-1024x969.png\" alt=\"\" class=\"wp-image-47458\" srcset=\"https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/Screenshot-2026-10-02-221624-1024x969.png 1024w, https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/Screenshot-2026-10-02-221624-767x726.png 767w, https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/Screenshot-2026-10-02-221624-300x284.png 300w, https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/Screenshot-2026-10-02-221624.png 1243w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><em>The governed setup, same kind of step: every name in the stored search result is a token.<\/em><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So the observability layer is one more copy of the data. In the Langfuse 4.50 I ran, the table holding the spans carries a full-text index on every span&#8217;s input and output, so questions, retrieved documents and answers are searchable word by word. On a self-hosted instance that copy is kept indefinitely unless you configure retention, a setting <a href=\"https:\/\/langfuse.com\/docs\/administration\/data-retention\" target=\"_blank\" rel=\"noopener\">its documentation<\/a> lists only under the Enterprise Edition. The SDK has <a href=\"https:\/\/langfuse.com\/docs\/observability\/features\/masking\" target=\"_blank\" rel=\"noopener\">masking hooks<\/a> that run before trace data leaves the application; that is where I would plug the gate&#8217;s scanner, so the trace store can never hold more than the provider was allowed to see. I have not run that variant yet.<\/p>\n\n\n\n<h2 id=\"h-what-the-plans-show\" class=\"wp-block-heading\">What the plans show<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The dense path as <code>app_agent<\/code> with <code>bank_a<\/code> set, using a stored vector as the query. The query has no bank predicate:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: sql; title: ; notranslate\" title=\"\">\nEXPLAIN (ANALYZE, BUFFERS, COSTS OFF)\nSELECT e.doc_id FROM bank.embeddings e\nJOIN bank.embedding_versions v USING (version_id)\nWHERE v.state = &#039;tokenized&#039; AND v.is_active\nORDER BY e.dense &lt;=&gt; :&#039;qd&#039;::vector LIMIT 10;\n\n<\/pre><\/div>\n\n\n<pre class=\"wp-block-preformatted\"> <code>Limit (actual time=1.936..1.938 rows=10.00 loops=1)\n   Buffers: shared hit=1762\n   -&gt;  Sort (actual time=1.935..1.936 rows=10.00 loops=1)\n         Sort Method: top-N heapsort  Memory: 25kB\n         -&gt;  Nested Loop (actual time=0.051..1.850 rows=508.00 loops=1)\n               Buffers: shared hit=1759\n               -&gt;  Index Scan using embedding_versions_one_active on embedding_versions v (actual time=0.006..0.006 rows=1.00 loops=1)\n                     Index Cond: (state = 'tokenized'::text)\n               -&gt;  Index Scan using embeddings_version_id_bank_id_idx on embeddings e (actual time=0.019..0.144 rows=508.00 loops=1)\n                     Index Cond: ((version_id = v.version_id) AND (bank_id = current_setting('app.bank_id'::text, true)))\n                     Buffers: shared hit=64\n Execution Time: 2.234 ms\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">(Shortened: sort key, index searches and planning lines removed.)<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Reading it from the bottom: the active version comes from the partial unique index, then an index scan on <code>(version_id, bank_id)<\/code> returns the 508 rows of one bank and one version in 64 buffers, and a top-N heapsort scores all of them exactly and keeps ten. At this size the planner does not use the HNSW index at all.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two details are worth the read. The <code>bank_id = current_setting(...)<\/code> condition is not in the query: it comes from the policy, and because the index is on <code>(version_id, bank_id)<\/code>, the planner used it as an index condition next to the version. On the mention index above, with no <code>bank_id<\/code> in the B-tree, the same predicate was a filter after the lookup: where row-level security lands in a plan depends on your indexes. And of the 1,762 buffers, the index scan accounts for 64; the rest is reading the vectors back. pgvector stores <code>vector<\/code> with the <code>EXTERNAL<\/code> storage mode, and at about 4 KB each (1,024 floats plus a small header) the dense vectors live out of line: 48 MB of TOAST against 8 MB of heap, for 9,126 rows across the six embedding versions the table holds. The sparse vectors average 773 bytes and stay inline, and the same plan on the sparse path reads 66 buffers. Over five repeated runs the dense path took 1.9 to 4.5 ms, the sparse path under 1 ms, and the hybrid function 5 to 14 ms.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">With much larger tables per bank the planner may switch to the HNSW index, and a plain HNSW scan stops after <code>ef_search<\/code> candidates, some of which row-level security and the version filter then remove, since one HNSW index spans every version. That is why <code>bank.retrieve()<\/code> carries <code>SET hnsw.iterative_scan = strict_order<\/code> and <code>SET hnsw.ef_search = 100<\/code> in its definition: the index keeps scanning until enough rows pass the filter or its configured scan limits are reached, and the settings cannot leak into the caller&#8217;s session. I did not measure where that switch happens.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Underneath, there is nothing exotic. Tenant, version and entity are predicates on ordinary indexes, the vault is another PostgreSQL server with revoked defaults, the gate is a short function writing to a table, and the vectors are TOASTed values like any other. Every layer is something a DBA already knows how to operate, back up and audit.<\/p>\n\n\n\n<h2 id=\"h-a-note-on-method\" class=\"wp-block-heading\">A note on method<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Everything above was captured from runs of the lab on 1, 2 and 3 October 2026, command by command, and the runbook in the repository replays it, including the two comparison runs (step 19). The data is synthetic and generated with a fixed seed; vault tokens differ per vault (different hex, same behaviour). Embeddings ran on a CPU, between about 0.7 and 3.5 documents per second depending on the run, which is why the corpus is 1,521 documents and not a million.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The question set is small and generous: each entity question names one entity, and the generic ones are too easy. An outside review of an earlier run found questions whose wording matched only one of the ways their documents phrase the topic, which rewarded a correct &#8220;nothing found&#8221;; I fixed the wording and every number above uses the corrected set. Answers come from a model and vary between runs, which is why the compared setups were always run together. These numbers show the mechanism on this corpus; they are not a production benchmark.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">&#8220;Zero&#8221; in this post always means zero detected occurrences: zero matches against the raw values of the bank&#8217;s sensitive columns and a few patterns, which a name in no table escapes. The scanner reads the payload with accents intact, so a name like M\u00fcller is matched too, though the lab data itself is plain ASCII. An outcome of <code>sent<\/code> in the egress log means the provider accepted the request; a request logged as failed may still have reached it before the error, so the log proves what was sent, not everything that was attempted. Key rotation is scripted but I have not run it end to end, and re-tokenizing overwrites the tokenized text in place, so older vector versions are not a full rollback. The agent ran on <code>gpt-6-luna<\/code> with reasoning turned off: in my runs, Chat Completions refused function tools for that model unless it was.<\/p>\n\n\n\n<h2 id=\"h-takeaways\" class=\"wp-block-heading\">Takeaways<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">I often hear that you cannot keep sensitive data out of an AI pipeline and still get useful answers. On this lab you can, with one condition: labelling. Row-level security on the vectors decides which rows a session sees (which, as I showed in my <a href=\"https:\/\/2026.pgday.ch\/session\/388-the-cost-of-security-debt-in-postgresql-when-implementing-ai-workflows\/\" target=\"_blank\" rel=\"noopener\">Swiss PGDay talk<\/a>, also improves precision), the labels decide what is sensitive, deterministic tokens replace it the same way every time, the gate proves what left, and the vault keeps the way back, for everything downstream of the filter, on another server. With the same search and tools, the answers on tokens were as good as the answers on raw text, and nothing known left. And the tool you add to watch all of it becomes a copy of the data unless what it records is filtered too.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This transfers to any assistant over CRM notes, a ticketing tool or a CMDB, wherever the useful text names people and systems, and it does not depend on which model you use or where it runs. Its weak points are said above: names that live in no table, a tracing store that sits outside the gate unless you put it behind, and a token that matches strings, not identities. It also makes the token mapping sensitive in its own, a secret with a required life cycle somehow. <br>A stale mapping increases the risk of a leak; a rotated one has to keep its history, for traceability and for questions that span a period with several mapping versions. How long to keep which version, and who may read it, is a question the business has to answer, and the real answer is probably a dynamic mapping system. This lab shows that the approach is possible, <strong>not how to run it in production<\/strong>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Agentic projects put pressure on governance at a level organizations have never known, and it is understandable to feel overwhelmed, even afraid. To me this is an engineering problem, and we already hold most of the keys; the challenge is that the storm we are living through makes it hard to see clearly. Observability and open source will prevail !<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><\/p>\n","protected":false},"excerpt":{"rendered":"<p>Every useful database content, document behind an internal AI agent names someone, a company, server names,&#8230; A relationship manager might ask &#8220;what did this client complain about last quarter?&#8221;, an operations engineer might aska &#8220;what happened on this server last month?&#8221;, and the answer sits in notes, emails and incident tickets, CRMs and ERPs data, [&hellip;]<\/p>\n","protected":false},"author":153,"featured_media":47460,"comment_status":"open","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"_acf_changed":false,"footnotes":"","_members_access_role":[],"_members_access_error":""},"categories":[83,149],"tags":[2810,3576,4253,590,4254,2602],"type_dbi":[],"class_list":["post-47446","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-postgresql","category-security","tag-ai","tag-ai-agent","tag-clickhouse","tag-governance","tag-langfuse","tag-postgresql-2"],"acf":[],"yoast_head":"<!-- This site is optimized with the Yoast SEO Premium plugin v28.5 (Yoast SEO v28.6) - https:\/\/yoast.com\/product\/yoast-seo-premium-wordpress\/ -->\n<title>AI agents on sensitive data: what PostgreSQL can enforce, and what it can&#039;t - dbi Blog<\/title>\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\n<link rel=\"canonical\" href=\"https:\/\/www.dbi-services.com\/blog\/ai-agents-on-sensitive-data-what-postgresql-can-enforce-and-what-it-cant\/\" \/>\n<meta property=\"og:locale\" content=\"en_US\" \/>\n<meta property=\"og:type\" content=\"article\" \/>\n<meta property=\"og:title\" content=\"AI agents on sensitive data: what PostgreSQL can enforce, and what it can&#039;t\" \/>\n<meta property=\"og:description\" content=\"Every useful database content, document behind an internal AI agent names someone, a company, server names,&#8230; A relationship manager might ask &#8220;what did this client complain about last quarter?&#8221;, an operations engineer might aska &#8220;what happened on this server last month?&#8221;, and the answer sits in notes, emails and incident tickets, CRMs and ERPs data, [&hellip;]\" \/>\n<meta property=\"og:url\" content=\"https:\/\/www.dbi-services.com\/blog\/ai-agents-on-sensitive-data-what-postgresql-can-enforce-and-what-it-cant\/\" \/>\n<meta property=\"og:site_name\" content=\"dbi Blog\" \/>\n<meta property=\"article:published_time\" content=\"2026-10-03T13:22:11+00:00\" \/>\n<meta property=\"article:modified_time\" content=\"2026-10-03T13:22:13+00:00\" \/>\n<meta property=\"og:image\" content=\"http:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/284623ef-1d98-4f02-91bb-466a75208e46.png\" \/>\n\t<meta property=\"og:image:width\" content=\"1774\" \/>\n\t<meta property=\"og:image:height\" content=\"887\" \/>\n\t<meta property=\"og:image:type\" content=\"image\/png\" \/>\n<meta name=\"author\" content=\"Adrien Obernesser\" \/>\n<meta name=\"twitter:card\" content=\"summary_large_image\" \/>\n<meta name=\"twitter:label1\" content=\"Written by\" \/>\n\t<meta name=\"twitter:data1\" content=\"Adrien Obernesser\" \/>\n\t<meta name=\"twitter:label2\" content=\"Est. reading time\" \/>\n\t<meta name=\"twitter:data2\" content=\"26 minutes\" \/>\n<script type=\"application\/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https:\\\/\\\/schema.org\",\"@graph\":[{\"@type\":\"Article\",\"@id\":\"https:\\\/\\\/www.dbi-services.com\\\/blog\\\/ai-agents-on-sensitive-data-what-postgresql-can-enforce-and-what-it-cant\\\/#article\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/www.dbi-services.com\\\/blog\\\/ai-agents-on-sensitive-data-what-postgresql-can-enforce-and-what-it-cant\\\/\"},\"author\":{\"name\":\"Adrien Obernesser\",\"@id\":\"https:\\\/\\\/www.dbi-services.com\\\/blog\\\/#\\\/schema\\\/person\\\/fd2ab917212ce0200c7618afaa7fdbcd\"},\"headline\":\"AI agents on sensitive data: what PostgreSQL can enforce, and what it can&#8217;t\",\"datePublished\":\"2026-10-03T13:22:11+00:00\",\"dateModified\":\"2026-10-03T13:22:13+00:00\",\"mainEntityOfPage\":{\"@id\":\"https:\\\/\\\/www.dbi-services.com\\\/blog\\\/ai-agents-on-sensitive-data-what-postgresql-can-enforce-and-what-it-cant\\\/\"},\"wordCount\":5459,\"commentCount\":0,\"image\":{\"@id\":\"https:\\\/\\\/www.dbi-services.com\\\/blog\\\/ai-agents-on-sensitive-data-what-postgresql-can-enforce-and-what-it-cant\\\/#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/www.dbi-services.com\\\/blog\\\/wp-content\\\/uploads\\\/sites\\\/2\\\/2026\\\/10\\\/284623ef-1d98-4f02-91bb-466a75208e46.png\",\"keywords\":[\"ai\",\"AI Agent\",\"Clickhouse\",\"Governance\",\"Langfuse\",\"postgresql\"],\"articleSection\":[\"PostgreSQL\",\"Security\"],\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"CommentAction\",\"name\":\"Comment\",\"target\":[\"https:\\\/\\\/www.dbi-services.com\\\/blog\\\/ai-agents-on-sensitive-data-what-postgresql-can-enforce-and-what-it-cant\\\/#respond\"]}]},{\"@type\":\"WebPage\",\"@id\":\"https:\\\/\\\/www.dbi-services.com\\\/blog\\\/ai-agents-on-sensitive-data-what-postgresql-can-enforce-and-what-it-cant\\\/\",\"url\":\"https:\\\/\\\/www.dbi-services.com\\\/blog\\\/ai-agents-on-sensitive-data-what-postgresql-can-enforce-and-what-it-cant\\\/\",\"name\":\"AI agents on sensitive data: what PostgreSQL can enforce, and what it can't - dbi Blog\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/www.dbi-services.com\\\/blog\\\/#website\"},\"primaryImageOfPage\":{\"@id\":\"https:\\\/\\\/www.dbi-services.com\\\/blog\\\/ai-agents-on-sensitive-data-what-postgresql-can-enforce-and-what-it-cant\\\/#primaryimage\"},\"image\":{\"@id\":\"https:\\\/\\\/www.dbi-services.com\\\/blog\\\/ai-agents-on-sensitive-data-what-postgresql-can-enforce-and-what-it-cant\\\/#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/www.dbi-services.com\\\/blog\\\/wp-content\\\/uploads\\\/sites\\\/2\\\/2026\\\/10\\\/284623ef-1d98-4f02-91bb-466a75208e46.png\",\"datePublished\":\"2026-10-03T13:22:11+00:00\",\"dateModified\":\"2026-10-03T13:22:13+00:00\",\"author\":{\"@id\":\"https:\\\/\\\/www.dbi-services.com\\\/blog\\\/#\\\/schema\\\/person\\\/fd2ab917212ce0200c7618afaa7fdbcd\"},\"breadcrumb\":{\"@id\":\"https:\\\/\\\/www.dbi-services.com\\\/blog\\\/ai-agents-on-sensitive-data-what-postgresql-can-enforce-and-what-it-cant\\\/#breadcrumb\"},\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https:\\\/\\\/www.dbi-services.com\\\/blog\\\/ai-agents-on-sensitive-data-what-postgresql-can-enforce-and-what-it-cant\\\/\"]}]},{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/www.dbi-services.com\\\/blog\\\/ai-agents-on-sensitive-data-what-postgresql-can-enforce-and-what-it-cant\\\/#primaryimage\",\"url\":\"https:\\\/\\\/www.dbi-services.com\\\/blog\\\/wp-content\\\/uploads\\\/sites\\\/2\\\/2026\\\/10\\\/284623ef-1d98-4f02-91bb-466a75208e46.png\",\"contentUrl\":\"https:\\\/\\\/www.dbi-services.com\\\/blog\\\/wp-content\\\/uploads\\\/sites\\\/2\\\/2026\\\/10\\\/284623ef-1d98-4f02-91bb-466a75208e46.png\",\"width\":1774,\"height\":887},{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\\\/\\\/www.dbi-services.com\\\/blog\\\/ai-agents-on-sensitive-data-what-postgresql-can-enforce-and-what-it-cant\\\/#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Accueil\",\"item\":\"https:\\\/\\\/www.dbi-services.com\\\/blog\\\/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"AI agents on sensitive data: what PostgreSQL can enforce, and what it can&#8217;t\"}]},{\"@type\":\"WebSite\",\"@id\":\"https:\\\/\\\/www.dbi-services.com\\\/blog\\\/#website\",\"url\":\"https:\\\/\\\/www.dbi-services.com\\\/blog\\\/\",\"name\":\"dbi Blog\",\"description\":\"\",\"potentialAction\":[{\"@type\":\"SearchAction\",\"target\":{\"@type\":\"EntryPoint\",\"urlTemplate\":\"https:\\\/\\\/www.dbi-services.com\\\/blog\\\/?s={search_term_string}\"},\"query-input\":{\"@type\":\"PropertyValueSpecification\",\"valueRequired\":true,\"valueName\":\"search_term_string\"}}],\"inLanguage\":\"en-US\"},{\"@type\":\"Person\",\"@id\":\"https:\\\/\\\/www.dbi-services.com\\\/blog\\\/#\\\/schema\\\/person\\\/fd2ab917212ce0200c7618afaa7fdbcd\",\"name\":\"Adrien Obernesser\",\"image\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/secure.gravatar.com\\\/avatar\\\/dc9316c729e50107159e0a1e631b9c1742ce8898576887d0103c83b1ca3bc9e6?s=96&d=mm&r=g\",\"url\":\"https:\\\/\\\/secure.gravatar.com\\\/avatar\\\/dc9316c729e50107159e0a1e631b9c1742ce8898576887d0103c83b1ca3bc9e6?s=96&d=mm&r=g\",\"contentUrl\":\"https:\\\/\\\/secure.gravatar.com\\\/avatar\\\/dc9316c729e50107159e0a1e631b9c1742ce8898576887d0103c83b1ca3bc9e6?s=96&d=mm&r=g\",\"caption\":\"Adrien Obernesser\"},\"url\":\"https:\\\/\\\/www.dbi-services.com\\\/blog\\\/author\\\/adrienobernesser\\\/\"}]}<\/script>\n<!-- \/ Yoast SEO Premium plugin. -->","yoast_head_json":{"title":"AI agents on sensitive data: what PostgreSQL can enforce, and what it can't - dbi Blog","robots":{"index":"index","follow":"follow","max-snippet":"max-snippet:-1","max-image-preview":"max-image-preview:large","max-video-preview":"max-video-preview:-1"},"canonical":"https:\/\/www.dbi-services.com\/blog\/ai-agents-on-sensitive-data-what-postgresql-can-enforce-and-what-it-cant\/","og_locale":"en_US","og_type":"article","og_title":"AI agents on sensitive data: what PostgreSQL can enforce, and what it can't","og_description":"Every useful database content, document behind an internal AI agent names someone, a company, server names,&#8230; A relationship manager might ask &#8220;what did this client complain about last quarter?&#8221;, an operations engineer might aska &#8220;what happened on this server last month?&#8221;, and the answer sits in notes, emails and incident tickets, CRMs and ERPs data, [&hellip;]","og_url":"https:\/\/www.dbi-services.com\/blog\/ai-agents-on-sensitive-data-what-postgresql-can-enforce-and-what-it-cant\/","og_site_name":"dbi Blog","article_published_time":"2026-10-03T13:22:11+00:00","article_modified_time":"2026-10-03T13:22:13+00:00","og_image":[{"width":1774,"height":887,"url":"http:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/284623ef-1d98-4f02-91bb-466a75208e46.png","type":"image\/png"}],"author":"Adrien Obernesser","twitter_card":"summary_large_image","twitter_misc":{"Written by":"Adrien Obernesser","Est. reading time":"26 minutes"},"schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"Article","@id":"https:\/\/www.dbi-services.com\/blog\/ai-agents-on-sensitive-data-what-postgresql-can-enforce-and-what-it-cant\/#article","isPartOf":{"@id":"https:\/\/www.dbi-services.com\/blog\/ai-agents-on-sensitive-data-what-postgresql-can-enforce-and-what-it-cant\/"},"author":{"name":"Adrien Obernesser","@id":"https:\/\/www.dbi-services.com\/blog\/#\/schema\/person\/fd2ab917212ce0200c7618afaa7fdbcd"},"headline":"AI agents on sensitive data: what PostgreSQL can enforce, and what it can&#8217;t","datePublished":"2026-10-03T13:22:11+00:00","dateModified":"2026-10-03T13:22:13+00:00","mainEntityOfPage":{"@id":"https:\/\/www.dbi-services.com\/blog\/ai-agents-on-sensitive-data-what-postgresql-can-enforce-and-what-it-cant\/"},"wordCount":5459,"commentCount":0,"image":{"@id":"https:\/\/www.dbi-services.com\/blog\/ai-agents-on-sensitive-data-what-postgresql-can-enforce-and-what-it-cant\/#primaryimage"},"thumbnailUrl":"https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/284623ef-1d98-4f02-91bb-466a75208e46.png","keywords":["ai","AI Agent","Clickhouse","Governance","Langfuse","postgresql"],"articleSection":["PostgreSQL","Security"],"inLanguage":"en-US","potentialAction":[{"@type":"CommentAction","name":"Comment","target":["https:\/\/www.dbi-services.com\/blog\/ai-agents-on-sensitive-data-what-postgresql-can-enforce-and-what-it-cant\/#respond"]}]},{"@type":"WebPage","@id":"https:\/\/www.dbi-services.com\/blog\/ai-agents-on-sensitive-data-what-postgresql-can-enforce-and-what-it-cant\/","url":"https:\/\/www.dbi-services.com\/blog\/ai-agents-on-sensitive-data-what-postgresql-can-enforce-and-what-it-cant\/","name":"AI agents on sensitive data: what PostgreSQL can enforce, and what it can't - dbi Blog","isPartOf":{"@id":"https:\/\/www.dbi-services.com\/blog\/#website"},"primaryImageOfPage":{"@id":"https:\/\/www.dbi-services.com\/blog\/ai-agents-on-sensitive-data-what-postgresql-can-enforce-and-what-it-cant\/#primaryimage"},"image":{"@id":"https:\/\/www.dbi-services.com\/blog\/ai-agents-on-sensitive-data-what-postgresql-can-enforce-and-what-it-cant\/#primaryimage"},"thumbnailUrl":"https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/284623ef-1d98-4f02-91bb-466a75208e46.png","datePublished":"2026-10-03T13:22:11+00:00","dateModified":"2026-10-03T13:22:13+00:00","author":{"@id":"https:\/\/www.dbi-services.com\/blog\/#\/schema\/person\/fd2ab917212ce0200c7618afaa7fdbcd"},"breadcrumb":{"@id":"https:\/\/www.dbi-services.com\/blog\/ai-agents-on-sensitive-data-what-postgresql-can-enforce-and-what-it-cant\/#breadcrumb"},"inLanguage":"en-US","potentialAction":[{"@type":"ReadAction","target":["https:\/\/www.dbi-services.com\/blog\/ai-agents-on-sensitive-data-what-postgresql-can-enforce-and-what-it-cant\/"]}]},{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/www.dbi-services.com\/blog\/ai-agents-on-sensitive-data-what-postgresql-can-enforce-and-what-it-cant\/#primaryimage","url":"https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/284623ef-1d98-4f02-91bb-466a75208e46.png","contentUrl":"https:\/\/www.dbi-services.com\/blog\/wp-content\/uploads\/sites\/2\/2026\/10\/284623ef-1d98-4f02-91bb-466a75208e46.png","width":1774,"height":887},{"@type":"BreadcrumbList","@id":"https:\/\/www.dbi-services.com\/blog\/ai-agents-on-sensitive-data-what-postgresql-can-enforce-and-what-it-cant\/#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Accueil","item":"https:\/\/www.dbi-services.com\/blog\/"},{"@type":"ListItem","position":2,"name":"AI agents on sensitive data: what PostgreSQL can enforce, and what it can&#8217;t"}]},{"@type":"WebSite","@id":"https:\/\/www.dbi-services.com\/blog\/#website","url":"https:\/\/www.dbi-services.com\/blog\/","name":"dbi Blog","description":"","potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/www.dbi-services.com\/blog\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"en-US"},{"@type":"Person","@id":"https:\/\/www.dbi-services.com\/blog\/#\/schema\/person\/fd2ab917212ce0200c7618afaa7fdbcd","name":"Adrien Obernesser","image":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/secure.gravatar.com\/avatar\/dc9316c729e50107159e0a1e631b9c1742ce8898576887d0103c83b1ca3bc9e6?s=96&d=mm&r=g","url":"https:\/\/secure.gravatar.com\/avatar\/dc9316c729e50107159e0a1e631b9c1742ce8898576887d0103c83b1ca3bc9e6?s=96&d=mm&r=g","contentUrl":"https:\/\/secure.gravatar.com\/avatar\/dc9316c729e50107159e0a1e631b9c1742ce8898576887d0103c83b1ca3bc9e6?s=96&d=mm&r=g","caption":"Adrien Obernesser"},"url":"https:\/\/www.dbi-services.com\/blog\/author\/adrienobernesser\/"}]}},"_links":{"self":[{"href":"https:\/\/www.dbi-services.com\/blog\/wp-json\/wp\/v2\/posts\/47446","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.dbi-services.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.dbi-services.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.dbi-services.com\/blog\/wp-json\/wp\/v2\/users\/153"}],"replies":[{"embeddable":true,"href":"https:\/\/www.dbi-services.com\/blog\/wp-json\/wp\/v2\/comments?post=47446"}],"version-history":[{"count":21,"href":"https:\/\/www.dbi-services.com\/blog\/wp-json\/wp\/v2\/posts\/47446\/revisions"}],"predecessor-version":[{"id":47476,"href":"https:\/\/www.dbi-services.com\/blog\/wp-json\/wp\/v2\/posts\/47446\/revisions\/47476"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.dbi-services.com\/blog\/wp-json\/wp\/v2\/media\/47460"}],"wp:attachment":[{"href":"https:\/\/www.dbi-services.com\/blog\/wp-json\/wp\/v2\/media?parent=47446"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.dbi-services.com\/blog\/wp-json\/wp\/v2\/categories?post=47446"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.dbi-services.com\/blog\/wp-json\/wp\/v2\/tags?post=47446"},{"taxonomy":"type","embeddable":true,"href":"https:\/\/www.dbi-services.com\/blog\/wp-json\/wp\/v2\/type_dbi?post=47446"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}