Skip to content

What leaves the machine

dbportal deploys next to the data, and the model is the only thing it talks to that you did not name. This page is about that traffic and only that traffic: what leaves for a model provider, when, to which provider, and where the labelling boundary applies.

It is not the whole egress boundary, and does not claim to be. Three other things leave this host, and an operator writing a firewall rule needs all four:

What To where Call site
Model traffic The provider you configured through LLM_* The rest of this page
Database traffic Every database host in your connections — the product’s entire purpose src/lib/db/providers/
OIDC Your issuer, when NEXT_PUBLIC_AUTH_PROVIDER=oidc: discovery, token and JWKS requests src/lib/oidc.ts

It is written from call sites, not from intent. Every claim below names the file and the lines that make it true, so you can check any sentence against the code rather than believing this page. Where something is not protected, that is stated here too — an honest egress page is worth nothing if it only lists the good news.



Surface What it sends Fenced?
POST /api/agent/classify, before any run exists Your objective, and nothing else. One short completion that asks the model to name one of the five workflows. It fires when you press Start with the workflow left on Automatic, which is the default; naming a workflow yourself under Advanced skips it entirely. See the classification, before any run No — it carries no database content to fence. Your objective is sent as the user message, and the server’s instructions tell the model to treat that text as data to classify and never as instructions to it
Any run, in either mode, on any engine Your objective, and a schema inventory (table, column, index identifiers and column types) with its relations graph (identifiers only). Since #414 that inventory leaves on every engine, where before it left on PostgreSQL and SQLite alone: those two are read with catalog statements the server composes, and the other fifteen by asking the connection’s own provider to describe its schema. On MongoDB and Couchbase the provider works out a collection’s fields from a sample of your own documents — no value from them is in the message, but the existence of a field there is derived from your data rather than read from a catalog, which is a weaker claim than a catalog reading’s and is why that reading has its own operation id (db.schema.read) an operator can deny alone. When the reading cannot be taken — a refusal, a provider that cannot describe its own schema, a description that overran the time the run granted it — nothing of the schema leaves and a server sentence saying which of those happened goes in its place. See the schema inventory Everything derived from the database is wrapped in an untrusted-content fence before it reaches a prompt
An agent run The above, plus the rows of each read the model performed, up to 200 per read; engine error text; server-written refusals; server-minted ids Same fence
Any run that continues a conversation, in either mode, on any engine In addition to everything above: the earlier steps’ objectives — your own earlier questions, capped at 200 characters each — and the most recent step’s report, which is a model’s claims about your data. No row of any result, and no earlier step’s report but the newest. Sent only when the rail attaches a previous run’s id, which it does for a follow-up on the same connection; a run that starts its own conversation sends no such message. Bounded to 4000 characters by default and switchable off with DBPORTAL_AGENT_THREAD_CONTEXT=false. See the conversation Same fence, identified as operation agent/thread: it is prose a user and a model wrote, and none of it is the server’s voice
An agent run opened as Operate Your objective; a schema inventory reduced to its own names and index names (no column names, no column types, no relations graph), read whichever of the two ways that engine is read and named with whichever noun that engine’s provider declares — tables, collections, datasources, key patterns; in Plan mode the engine’s row-count estimates for those same tables where the engine holds any — PostgreSQL and SQLite — and nothing per column; and the rows of each curated reading — which, for the sessions and slow-queries kinds, include other database users’ in-flight statement text and their database usernames. See the operations workflow Same fence: the inventory and every reading’s rows are database content and are fenced
POST /api/ai/explain Your statement, the EXPLAIN plan, the schema context the browser holds, the engine type No
POST /api/ai/query-safety Your statement, a filtered schema context, the engine type No
POST /api/ai/describe-schema A schema context. From the Data Profiler that context includes a per-column min= and max=, which are real column values No
Everything else in the running server Every outbound connection goes to a host you configured — your databases, your OIDC issuer when NEXT_PUBLIC_AUTH_PROVIDER=oidc (src/lib/oidc.ts), and the model provider above. The one host src/ names itself is OpenAI’s default base URL, reached only if you set LLM_PROVIDER=openai (src/lib/llm/utils/config.ts:23) —

Exactly one place: the model provider you configured through LLM_PROVIDER, LLM_MODEL, LLM_API_KEY and LLM_API_URL. The agent resolves that configuration through src/lib/llm’s own resolution — the same one the remaining AI features use — so there is one answer rather than two that can disagree (src/lib/agent/model-adapter.ts).

Kind Endpoint Call site
gemini {LLM_API_URL} when you set one, Google’s own Generative AI endpoint otherwise. The variable is read on both Gemini surfaces, so a proxy configured for the chat surface is also where a run goes — nothing routes to a host you did not name, and nothing silently keeps talking to Google either src/lib/agent/provider-registry.ts:136-150, src/lib/llm/utils/gemini-endpoint.ts
openai, ollama, custom {LLM_API_URL}/chat/completions — whatever host you named src/lib/agent/provider-registry.ts:123-134

Two properties of that seam are enforced rather than assumed:

  • Ambient provider keys cannot authenticate a run. @ai-sdk/openai reads OPENAI_API_KEY and OPENAI_BASE_URL when they are undefined, and @ai-sdk/google reads GOOGLE_GENERATIVE_AI_API_KEY. Every setting is therefore passed explicitly, so none of those reads can happen — a keyless Ollama gets a placeholder token and a keyless custom endpoint gets no Authorization header at all (provider-registry.ts:83-113, asserted on the wire in tests/isolated/agent-model-adapter.test.ts).
  • The SDK’s hosted gateway is unreachable by construction. The AI SDK also accepts a bare model id, which it resolves through its own gateway — a third party nothing in this repository configures. The type excludes that arm (AgentLanguageModel, provider-registry.ts:55-63).

LLM_API_URL reaches the gemini kind on the agent path exactly as on the chat surface, so a Gemini deployment behind an egress proxy or on a regional endpoint is configurable from the one variable. The two installed SDKs disagree about whether the version segment belongs to the base URL — @google/generative-ai appends it, @ai-sdk/google does not — so one value is normalised into both spellings in src/lib/llm/utils/gemini-endpoint.ts. Give the versioned URL; a bare origin also works, /v1beta is composed for it.


  • No model configured, no AI. With no model configured the agent is not available, the rail does not render, and every run-reaching agent route answers 404 (src/lib/agent/config.ts, docs/AGENT.md, “Turning it on”). The test is a configuration that validates, not a key. validateConfig demands LLM_API_KEY for the gemini and openai kinds only (src/lib/llm/utils/config.ts:127-134): LLM_PROVIDER=ollama validates with no key at all, and so does custom with an LLM_API_URL, and on either of those the agent is available and everything on this page is sent to that endpoint. What has no AI is a deployment carrying no LLM_* configuration at all — the provider then defaults to gemini (config.ts:12), which is refused without a key, and the availability answer is NO_MODEL_CONFIGURED.
  • GET /api/agent/config reaches no provider. The visibility probe the browser makes on mount answers from configuration and a local filesystem check; it makes no model call (src/app/api/agent/config/route.ts).
  • Opening the rail sends nothing. A shortcut fills the objective box and starts nothing (src/components/agent/use-agent-prefill.ts); the first model call happens when you press Start. Note that the first one is now the classification, which happens before a run exists — the section below is about it.
  • Naming the workflow yourself sends nothing extra. Choosing one of the five under Advanced skips the classification call entirely, so pressing Start then reaches the model exactly once before the run: the capability probe, which carries no text of yours.
  • A planning run runs no statement of yours. Its tool set is empty, so the model sends nothing and asks for nothing (selectAgentTools). Since 2026-08-15 the SERVER does read this connection’s catalog for it, before the first turn and through the same read-only, audited path an agent run uses (establishContext in src/lib/agent/investigation.ts): the alternative was a mode that knew your database only when an agent run had already read it in the same process. What that reads is a schema inventory — names, types, keys and relations — plus what the engine already RECORDS about its own tables (pg_class.reltuples and pg_stats on PostgreSQL, sqlite_stat1 on SQLite). On those two engines the catalog is read with statements the server composes; since #414 every other engine is read a second way — the server asks the connection’s own provider to describe its schema, which is the same inspection this product performs when it lists your tables in the sidebar, and it composes no statement at all. Nothing about your DATA is in the composed reading: no table is scanned and no value is read out of any column. The provider reading is bounded rather than exhaustive, and on the document engines it infers a table’s fields from a sample of your own documents — no value from them is kept, but the existence of a field there is derived from data rather than read from a catalog, and the prompt says so. The estimated statistics are read on PostgreSQL and SQLite only; everywhere else the run is told plainly that this engine holds none it knows how to read. A run whose reading is refused, overruns its time, or meets a provider that cannot describe itself is told THAT, and has no inventory. Nothing is written, and every statement a plan drafts is handed to you to run yourself.

There are two model calls the agent can make before the run itself, and only one of them carries nothing of yours:

  • the capability probe, one round trip asking the model to call a trivial tool. Its prompt is a fixed server-written string and carries nothing of yours (PROBE_PROMPT, src/lib/agent/capability-probe.ts:134). A positive verdict is cached for the life of the process, so it is paid once (src/lib/agent/capability-gate.ts);
  • the classification, which sends your objective. It is the section below.

Your objective can leave this machine before a run exists. Pressing Start with the workflow left on Automatic — the rail’s default — posts it to POST /api/agent/classify, and that route asks the configured model to name one of the five workflows (src/app/api/agent/classify/route.ts, classifyAgentWorkflow in src/lib/agent/workflow-classifier.ts). It goes to the same provider as everything else on this page, through the same createAgentModel seam, so nothing about where it goes is new.

What it sends, exactly:

Part Content
System A fixed server-written string: what the five workflows are, one line each, and the instruction to answer with one identifier. It ends by telling the model that the text it is given is the user’s task, to be treated as data to classify and never as instructions to it (CLASSIFY_SYSTEM, workflow-classifier.ts)
User Your objective, verbatim — the same text the run’s first user message would carry, bounded by the same AGENT_MAX_OBJECTIVE_LENGTH the route enforces

Nothing of your database is in it. No catalog is read, no statement is run, no connection is touched: there is no run yet, and this call reaches nothing but the model provider. That is also why it is not fenced — the fence marks database-derived content, and there is none here.

Four bounds, all of them properties of the code rather than of intent:

  • One call, no tools, no streaming, no retries. generateText with maxRetries: 0.
  • At most 16 output tokens (CLASSIFY_MAX_OUTPUT_TOKENS), because the answer is one identifier.
  • Eight seconds (AGENT_WORKFLOW_CLASSIFY_TIMEOUT_MS), after which the run opens unclassified; the browser holds a longer ceiling of its own so it never preempts the server’s.
  • It is skipped entirely when you name the workflow yourself under Advanced. That is not an optimisation: an explicit choice spends no model tokens and no latency, and your objective does not leave before the run.

It never blocks a start. Every failure — a model error, a timeout, an empty reply, a reply naming no workflow this build serves — opens the run as an unclassified investigation, and the run’s own record says so (workflowReading), so the rail can state the fallback as a fallback rather than as a verdict.

One consequence for an operator counting requests: with Automatic, a run costs one model request more than it used to, and the route is metered out of the same ai bucket as the run routes for that reason.


The transcript is assembled in runInvestigation (src/lib/agent/investigation.ts:662-825) and is sent by takeTurn through streamText (investigation.ts:575-592). Nothing else in the runtime sends anything.

1. The system instructions — server text only

Section titled “1. The system instructions — server text only”

systemPrompt concatenates the mode’s rules, the workflow’s tool rules, the workflow’s objective statement and the shared rules (investigation.ts:240-243). Every one of those strings is written in that file. Nothing of yours or of your database is in it — not your objective, not a table name, not a value.

One field of your connection record is: its engine type, the canonical type-id (postgres, sqlite, redis, …), which a plan run’s rules interpolate into server prose. A statement plan spends it on the fence tag the deliverable must carry, and since the Operate-engine fix a prose plan spends it twice: on the rule that binds the readings it may name to the engine it is planning against, and on the fence tag for a reading that engine happens to express as a statement. It is a server-side enum with seventeen members, so what it discloses is which of seventeen engines this connection is — never its host, its database name or its credentials, none of which reach a prompt at all (see What never leaves).

The first user message is the text you typed, unmodified apart from the trim the rail applies (investigation.ts:698). Bounded to 4000 characters by the route and by the rail (AGENT_MAX_OBJECTIVE_LENGTH).

2a. The conversation, when a run continues one — fenced

Section titled “2a. The conversation, when a run continues one — fenced”

This is the one message whose content came from an EARLIER question of yours. It is sent only when a run continues a conversation: a follow-up asked on the same connection, where the rail attaches the previous run’s id and the route derives the block server-side from those runs’ own ledgers (thread-context.ts, investigation.ts:1923). A run that starts a conversation of its own sends nothing here, and there is no such message at all.

What is in it, and where each half came from:

Half Content Whose words
The spine Every earlier step’s objective, oldest first, each capped at 200 characters Yours — the questions you typed, verbatim to the cap
The evidence The most recent step’s report: its answer statement, its claims, its closing prose A model’s, about your data — a claim is prose a model wrote after reading rows

A claim is derived from your rows, and that is the honest way to read this. No value from any row is copied into it, but a claim like “Sports has the most films (74)” is a statement ABOUT your data that a model composed from a result it read. So a conversation carries derived findings from one question into the next one’s prompt. That is the whole feature, and it is why there is an operator switch for it.

What is NOT in it. Not the rows of any result. Not an earlier step’s REPORT beyond the most recent one — only its objective travels, which is why the block says so in the server’s own voice outside the fence (“earlier steps may list only what was ASKED, not what was found”). Not the schema inventory, which is its own message below. Not a run id of anybody else’s: the route refuses to continue a run that is not this session’s, is on another connection, or has not ended.

Bounded and fenced. The whole block is capped (AGENT_THREAD_CONTEXT_MAX_CHARS, 4000 by default, sizeable per model through AGENT_MODEL_TUNING_PATH), the spine may take at most 75% of that, and every drop — a step past the cap, a report that did not fit, a report with no room at all — is stated in the text rather than performed silently. It is wrapped in the same untrusted-content fence every database-derived message gets, identified as operation agent/thread, because it is prose a user and a model wrote and none of it is the server’s voice.

Switched off, nothing here leaves. DBPORTAL_AGENT_THREAD_CONTEXT=false and every run opens on its own; the run’s own record says declined: "disabled" and the rail says so when a follow-up was not read as one. See docs/AGENT.md for the setting and the conversation a run belongs to for the model.

3. The schema inventory — identifiers and types, fenced

Section titled “3. The schema inventory — identifiers and types, fenced”

Less on an Operate run, and it changed in #411. A run whose workflow is operations used to capture no inventory at all. It now captures the same whole one every other workflow does — the capture is shared between runs through the process hold, so a workflow-shaped partial capture would later be handed to an investigation run as if it were complete — and what differs is how much of it is packed into a message. Concretely, what leaves the process on an Operate run is the inventory’s own names and its index names only — table names on a SQL engine, collection names on MongoDB, and on Redis and LibreDB the key prefixes described two paragraphs below (packOperationsInventory, context-snapshot.ts): no column names, no column types, no foreign keys, and section 4’s relations block is not sent at all. Under the same 6000-character bound and the same fence, with every identifier quoted inside it. A Plan-mode Operate run also gets section 6’s estimated statistics, and there too the reduction holds: row estimates only, with no per-column distinct count or null fraction, so no column name leaves the process on either mode of this workflow. Since #414 an engine outside PostgreSQL and SQLite is read through its own provider rather than refused on its dialect, so this reduced inventory leaves on those engines too; when the reading cannot be taken at all — a provider that cannot describe itself, a description that overran its time, a refusal — nothing of the schema leaves and a server-written note says so in its place.

Two readings produce it, and which one runs is the dialect’s decision (#414). On PostgreSQL and SQLite the server composes a catalog statement per kind and executes it read-only. On the other fifteen it invokes db.schema.read, which walks the connection’s own OBJECT SURFACE — the same listContainers, countObjects, listObjects and describeObjects the sidebar walks when it lists your objects — and composes no statement at all. Fifteen counts type-ids the factory can build, not engines a user would name: SHIPPED holds seventeen, CATALOG_PLANS serves two of them, and the remainder is what this second reading covers. Every other count said about grounding in these docs counts the same thing. libSQL is one of the fifteen and not one of the two: it speaks SQLite’s dialect, but the read-only catalog path CATALOG_PLANS serves needs a database-native read-only profile, and PRAGMA query_only is refused by a libSQL server (see providers/libsql.md). Two things it does NOT count. The wire-compatible engines of docs/providers/README.md are not extra members — TiDB is grounded because it arrives as mysql, and it is that type-id that is counted. And two of the fifteen, the embedded libredb and duckdb, reach this path through a handle they do not open: the file takes an exclusive lock, so the grounding acquisition borrows the connection’s own open provider rather than opening a second one that the lock would refuse (findOpenSingleWriterProvider, src/lib/db/factory.ts; see docs/providers/libredb.md §4.2.1 and docs/providers/duckdb.md). DuckDB is the sharper of the two: a second OPERATING-SYSTEM process is refused on that file even in read-only mode, measured, so the borrow is not an optimisation there but the only handle there is. A connection that configures an agentUser opts out of that borrow, and such a run is still honestly ungrounded. What leaves this process is the same KIND of thing either way, identifiers and types and nothing else, but two properties differ and are stated here rather than left to be discovered:

  • The provider reading is bounded, not exhaustive: MongoDB stops at 200 collections, Redis scans 1000 keys, LibreDB 10000. So the inventory is what that inspection found, and the prompt says so — the count in the fenced header is not a count of what the database holds.
  • On MongoDB and Couchbase the column names in it are inferred from a sample of your own documents (100 per collection on MongoDB; Couchbase’s INFER samples server-side). No value from those documents is kept or sent. But the existence of a field there is derived from your data rather than read from a catalog, and that is a different claim from every other line in this document — worth its own sentence for exactly that reason, and the reason db.schema.read is an operation id of its own rather than a detail of the capture.
  • On Redis and LibreDB the names are not names anything holds. The object walk scans a bounded slice of the keyspace and groups the real key names it found under their common prefix, so what leaves the process is one row per prefix — user:*, order:* — and each is a summary this server computed from your key names rather than an object the engine declares. The names it summarises are yours; the rows are not. Providers say which of the two they return (ProviderCapabilities.tablesAreDerivedGroupings), and where it is true a plan run is told so in one sentence, because a run that reads user:* as an addressable key drafts a command against something that does not exist (measured live, #414).

What the block CALLS these rows is the provider’s own word (#414). The prompt’s header, its omission notice and the relations block take their noun from ProviderLabels.entityName — “table” on every SQL engine, “collection” on MongoDB and Couchbase, “datasource” on Druid, “key pattern” on Redis, “key prefix” on LibreDB — so a Redis run reads “17 key pattern(s)” where it used to read “17 table(s)”. Nothing about a SQL engine’s prompt changes; the base provider’s label is the same word that was hard-coded before.

So on an Operate run this section is narrower than what follows and section 4 does not happen. On every other workflow, the inventory is captured once per run by whichever of the two readings that dialect gets, then packed for the task (packContextForTask, src/lib/agent/context-snapshot.ts:466-505). Per table it renders (renderTable, context-snapshot.ts:428-441):

  • the table name;
  • up to 12 columns, each as name type, plus NOT NULL, PK and -> referencedTable.column where they apply;
  • up to 4 index names with their column lists;
  • a count of what was left out, named as left out rather than silently dropped.

Tables are ordered by relevance to the words in your objective, and lines are added only while the fenced whole still fits 6000 characters (AGENT_CONTEXT_PACK_MAX_CHARS, context-snapshot.ts:348). Table and column names are database content — anyone who can write to the database writes them — so the block goes through fenceUntrustedContent before it is a message.

No row value is in this block. On the two engines whose fields are sampled, that sentence is still exactly true and no wider: a document was read to learn that a field exists, and the field’s name is what the block carries.

4. The relations block — identifiers only, quoted and escaped

Section titled “4. The relations block — identifiers only, quoted and escaped”

The inventory’s foreign keys, rendered as a relation list and fenced beside the inventory (packRelations, investigation.ts:288-294; rendering in src/lib/agent/er-diagram.ts:228-269). It carries table names, column names, and at the deepest detail level a table’s primary-key and leading-index column names (keyColumns, er-diagram.ts:145-157). Never a row value.

On the ten engines that declare no foreign keys at all (MongoDB, Redis, LibreDB, Druid, ClickHouse, Couchbase, Trino, Cassandra, Elasticsearch and OpenSearch) this block carries no relations and says why: the engine has no such constraint to declare, so there is nothing here a reading could have missed (#414, driven from ProviderCapabilities.declaresForeignKeys rather than from the connection’s type). It is one server sentence and no database content, which makes it the one form of this block that discloses nothing.

Every identifier is quoted with its delimiter doubled as SQL does it, and every control character is escaped, so a table named orders -> secrets renders as one quoted name rather than as a relation nobody has (quote/escapeInIdentifier, er-diagram.ts:67-87). The block is bounded at 2000 rendered characters — a bound in characters, because a count of edges is not a bound on a prompt (MAX_ER_CHARS, er-diagram.ts:96).

Outcome What is sent back to the model Call site
A completed read A server sentence naming the artifact id, then the rows: one JSON.stringify per row, newline separated, inside a fence labelled <what it was>, N row(s) tools.ts:918-932, rendering at tools.ts:620-624
A statement that failed at the database The engine’s own message, fenced, referenced by the statement’s fingerprint tools.ts:872-882
A policy denial Server text only: the deny code, the policy version, and advice that a boundary decided this. There is no engine text because a denial produced none denialText, tools.ts:568-574
An approval requirement Server text naming the operation id approvalText, tools.ts:576-581
A table profile (Assess) Counts, the number of findings, the covered column range — plus the table name and the finding lines (each column: code — detail), both fenced. No value from any column tools.ts:1325-1345, describeFindings at tools.ts:1143-1150
A curated operational reading (Operate) A server sentence naming the artifact id, then the projected rows, fenced like any other result. What those rows contain is the point: for sessions and slow-queries they carry statement text other users are running or have run, and for sessions the database username, client address and application name of each connected session. See below tools.ts, CURATED_READINGS
A plan comparison (Optimize) The two statements the run drafted and the server’s structural reading of each plan (full-scan / index / mixed / unknown, plus whatever estimates the engine reported) plan-summary.ts, restated at investigation.ts:367-369
A catalog read that could not be performed Fixed server advice, and one of two things before it. When the rows the read produced are already gone, when the connection’s provider cannot describe its own schema, or when that description overran the time the run granted it, it is a server sentence that may name the connection’s engine type (“a mongodb connection”) or the catalog kind (context-snapshot.ts:301-305,315-318). When the catalog read was itself refused, the sentence forwarded is inspectSchemaTool’s own modelText (context-snapshot.ts:312) — which for a policy denial is server text, and for a database-error refusal is the engine’s own message, fenced (tools.ts:872-882). Either way it is wrapped by unavailable (context-snapshot.ts:243-245) and pushed as a user message. Except on an Operate run, where the diagnosis is forwarded (capture.detail) and only the fixed ADVICE is replaced, because that advice sends a model to inspect_schema — a tool the workflow does not hold. So the same text reaches the prompt on an Operate run, including a fenced engine message, minus the sentence naming a tool that is not there

5a. The operations workflow: what a curated reading sends

Section titled “5a. The operations workflow: what a curated reading sends”

This is the one class of content no other agent path exposes, and it is stated here rather than left to be inferred from the fence. The operations workflow’s single database tool calls the provider’s own reporting methods, and two of its six kinds return text somebody else wrote:

  • sessions (getActiveSessions) projects, per connected session: query — the statement that session is running, literals and all — plus user (the database username), clientAddr, applicationName, database, state and wait information.
  • slow-queries (getSlowQueries) projects query for each statement the engine reports as costly, with its call counts and timings.

Those rows go into a prompt like any other result: fenced, bounded by the run’s row and byte caps, and never re-delivered on a resume. But a fence is a defence against injection, not a defence against disclosure — so if a model provider outside your machine is configured, an Operate run can send your colleagues’ running SQL and their database usernames to it. Three things follow, all of them yours to decide:

  • It only happens on a run you opened as Operate. No other workflow is offered the tool.
  • The reading has its own operation id (db.operations.read), so an operator can deny exactly this one in the audit stream without denying any other agent read.
  • The other four kinds (table-stats, index-stats, storage, health) carry identifiers, counts, sizes and timestamps — no statement text and no usernames.

6. On a resumed run, what the ledger already holds

Section titled “6. On a resumed run, what the ledger already holds”

A drive that resumes a run first tells the model what the run established, read off its own ledger (describePriorProgress, investigation.ts:340-423). That restatement carries: the statements the model drafted and their stated rationales, the artifact ids and row counts of settled steps (not the rows — “The rows themselves are not delivered again”), a recorded recommendation’s statement, a recorded comparison’s two statements and access kinds, and a profile’s depth, row count and artifact id with the table name fenced.


A model boundary has two directions, and the return one is where untrusted content actually lands: everything below is the model’s own text, arriving from the provider, and some of it is rendered to a user and some of it goes to the database.

The loop reads a response part by part rather than awaiting a result (takeTurn, investigation.ts:600-606) and keeps three things: the streamed prose, the tool calls, and the SDK’s own assistant messages (investigation.ts:625-631).

What the model returns What is done to it first Where it is displayed
Its assistant messages — its prose and the tool calls it made, together Nothing. They are appended to the transcript verbatim (investigation.ts:792), because rewriting an assistant turn would desynchronise tool_call_id, so they are not fenced and are resent on every later turn. This is the mechanism of B29 below Not displayed as such. Only the parts below reach the rail
A tool call’s arguments Parsed against that tool’s own schema first (parseToolInput, tools.ts:987). A shape the schema rejects is answered with server text and reaches no database —
The SQL of a read (run_read_query) After the schema, it goes to the database through the agent’s own audited pipeline, under the read-only execution profile (runReadQueryTool, tools.ts:1051-1058 → executeAgentOperation, tools.ts:809). It is recorded verbatim as a statement-drafted entry with the model’s rationale (investigation.ts:859) The rail: the SQL as a quoted block, the rationale as the entry’s detail (timeline.ts:378-385, rendered at AgentRail.tsx:693,699-703). Apply to editor puts the text in your editor and runs nothing (AgentRail.tsx:176-186)
A recommended statement and rationale (recommend_change) The statement is checked against the change it claims to be, and every citation against this run’s own ledger; either failing refuses it (tools.ts:1402-1407). Nothing executes it — the tool reaches no database The rail: statement quoted, rationale as detail, with the application’s own “Not applied: nothing here runs this statement.” appended to it (timeline.ts:421-430)
Report claims (compose_report) Every citation is verified against this run’s own event log, and one that resolves to nothing refuses the whole report (composeReportTool, tools.ts:1488-1489). The claim prose is not echoed back to the model: the tool’s reply is a count (tools.ts:1503-1505) Recorded (investigation.ts:1040) and rendered in the Report section, each claim a quoted block with its citations under it; a citation the rail cannot resolve reads amber rather than looking checked (AgentRail.tsx:745-758, timeline.ts:556)
Its closing prose Accumulated from the stream’s text deltas (investigation.ts:601, taken at :788) and recorded only when non-empty, so an empty entry cannot record that the model spoke (:773) The rail, under the application’s own headline Closing statement — as prose, with the headings, bullets, bold and inline code the model wrote (renderProse), inside a bordered block that keeps the quoting boundary visible

Three notes about that last column, because the rail’s own rule is narrower than it first reads:

  • One of those fields is the application’s detail, not the quoted block: a drafted statement’s rationale. The separation the rail enforces — the application’s words and text from elsewhere never share a field (timeline.ts:25-36) — is what keeps database content out of a sentence the user reads as the application speaking, and this one is model prose sitting in the field beside the application’s own headline. The closing prose used to be there too and is now a third field, prose, rendered as markdown in a block of its own: it arrives as headings and bullets, and one paragraph of literal hash marks is what a plan run’s whole output read as before.
  • None of it reaches an HTML parser. Every one of these is passed as a React child, so escaping is structural rather than a step someone applies; dangerouslySetInnerHTML appears nowhere under src/components/agent/. That is true of the markdown rendering too — renderProse builds React nodes and uses no markdown library, so structure is gained without a parser being introduced.
  • Nothing returned becomes an instruction to the next turn. The system instructions are built from constants once per drive (systemPrompt, investigation.ts:240-243, called at :697) and takeTurn is handed that same string unchanged on every turn (:787, used at :580).

0a4040b (“tell the model what a citation is”) changed what crosses the boundary, and the change is worth naming because it is instructions and identifiers rather than data:

  • The evidence contract, as an example object. AGENT_EVIDENCE_CONTRACT renders {"source":"artifact","correlationId":"…"} and {"source":"context-snapshot","fingerprint":"…"} into the run’s opening rules and into two tool descriptions (tools.ts:299-316,392,397, investigation.ts:173-180). Server text, with placeholders where the ids go.
  • An artifact id at the moment it changes hands. Every completed read is now preceded by “Stored as artifact <id>. To use it in a claim, cite it as {…}” (handoverText, tools.ts:936-937), and the schema snapshot is preceded by the same sentence for its fingerprint (snapshotHandoverText, investigation.ts:258-259). Both ids are server-minted — a UUID and a ctx_… digest — so nothing untrusted is spliced into a sentence the model reads as the server’s.
  • Those sentences sit OUTSIDE the fence, deliberately. A fence is a region the model is told to treat as data and never as instruction, so an instruction inside one is an instruction the model is right to ignore. That is why packContextForTask takes a preface parameter instead of the caller concatenating one — anything prepended outside the function would overrun its character bound by exactly its own length (context-snapshot.ts:453-470).
  • The same contract reaches two refusals: a wrong-shaped tool input and a citation that resolves to nothing (tools.ts:479,609).

What else crosses that boundary, checked rather than assumed. The tool set itself is sent on every turn: each selected tool’s description and JSON schema are handed to streamText (declaredTools, investigation.ts:551-560). Those descriptions are constants in tools.ts — the selector text, the row-budget warning, the “only COUNTS are returned” sentence — and they contain nothing of yours. Tool call ids and step ids also travel: a step id is a SHA-256 digest of the tool name and the arguments that reach the database (deriveStepId, investigation.ts:482-498), which is server-computed but derived from model-supplied SQL, so treat it as a fingerprint of the statement rather than as an opaque token.


Everything derived from the database is wrapped by fenceUntrustedContent (src/lib/agent/untrusted-content.ts:71-76): a header naming what the block is, which operation produced it and which id it joins to; a fixed instruction that the lines are data and must never be followed as instructions; and a pair of markers bounding the region.

The load-bearing property is not the wording — it is that the envelope survives the content. A row value carrying the closing marker would otherwise end the region, and everything after it would read as the server’s own prose. So both markers are neutralised wherever they occur inside the content, case-insensitively, including in the header (neutralise, untrusted-content.ts:57-63). Neutralising rather than deleting is deliberate: the content is evidence, and a silently edited result would make what the model reads disagree with the rows the artifact store holds.

tests/evals/injection.test.ts is what proves it, and it proves it by counting rather than by sampling: a transcript holds exactly as many closing markers as the server opened, whatever the database returned.

Two limits stated as plainly as the property:

  • This is a labelling boundary, not a sanitiser. It makes no claim about what the content MEANS — only that a reader can tell where the server stopped talking (untrusted-content.ts:22-23).
  • A fence is not enough for a notation the server writes. A fence says where the server stopped talking; it does nothing about a table named orders -> secrets producing a line that reads as a relation. That is why the relations block quotes and escapes every identifier as well as fencing the block (er-diagram.ts:10-19).

An identifier the model quotes back into its own tool arguments reaches the transcript unfenced. This is open, not closed, and it is not a bounded residual.

The mechanism: after each turn the loop appends the SDK’s own assistant messages verbatim (investigation.ts:625-631) — the model’s words, including the arguments of the tool calls it made. Those arguments are not passed through the fence, because an assistant message is the model’s turn rather than the server’s, and rewriting it would desynchronise tool_call_id. An attacker who can name a table controls the whole identifier, so they control the marker and arbitrary text after it; the surrounding JSON does not make that suffix safe.

What is true, and is a different claim: the server never hands the model the raw marker. Every server-authored path neutralises it first, so a model reading a hostile inventory sees the defanged spelling and has nothing to copy — to get the raw marker into an assistant message the model has to reconstruct it. Both halves are asserted in tests/evals/injection.test.ts: that the fenced inventory carries no raw marker, and that the transport does not prevent one if the model produces it anyway.

The other half of the boundary is what stops that mattering more than it does: obeying injected text changes nothing about what the run may do. A write is refused by the statement guard before the database, a tool the run was never offered does not exist for it, and a claim citing something the run never read cannot be composed — each asserted against the deciding function in tests/unit/agent-policy-gates.test.ts.

Full entry: docs/BACKLOG.md B29.


The frozen execution policies are the ceiling on one run’s egress, one row per workflow (AGENT_WORKFLOW_BUDGETS in src/lib/agent/execution-policy.ts):

Bound Value What it caps
maxResultRows / maxResultBytes 200 rows / 256 KiB The most one read can return — and therefore the most one tool result can send
maxStatementsPerRun 18-45, by workflow Reads per drive, grounding reads and repairs included — the composed catalog reads and, since #414, the one db.schema.read call that replaces them on the other fifteen. The figures did not move for it: that path is the cheapest of the three, so nothing had to be bought (docs/AGENT.md, the budget arithmetic)
AGENT_CONTEXT_PACK_MAX_CHARS 6000 The fenced schema inventory
MAX_ER_CHARS 2000 The fenced relations block
AGENT_MAX_OBJECTIVE_LENGTH 4000 Your objective
maxModelTurns 20-60, by workflow Model calls per drive

An oversized read is refused, not truncated, so a result that reached the model is a complete one. Note the honest edge: the comparison happens after the driver has materialised the rows, so an oversized read is refused but still paid for at the database.

Every one of these is per drive. A run resumed after a restart starts each of them again (docs/BACKLOG.md B6).

The classification is outside all of it, by construction: it happens before a run exists, so there is no budget to charge it to. Its own bounds are its 8-second ceiling and its 16-token output cap, and what it sends is one objective — see the classification, before any run.


  • Database credentials. A run persists a connection id and no credential, and the state guard refuses to persist anything credential-shaped, a client, a pool or a raw result set (src/lib/agent/state-guard.ts). No message assembled in investigation.ts carries a host, a port, a user or a password — the message array is built only from the sources listed above (investigation.ts:698-700,736-737,743,753-754,792,801).

    One credential does leave, necessarily: your LLM_API_KEY. It is not in a prompt — it is in the request’s authentication metadata, because that is how the provider authenticates you. The OpenAI-compatible kinds send it as the Authorization header (src/lib/agent/provider-registry.ts:108,125-128) and Gemini passes it to the Google provider (:143). Two consequences worth stating: a keyless Ollama gets a placeholder token and a keyless custom endpoint gets no Authorization header at all (:109,112), so neither invents a credential; and Gemini’s baseURL comes from LLM_API_URL — the host you configured for the chat surface and no other (:136-150). “No credentials leave” would be the wrong claim here — the accurate one is that no database credential reaches the model, while the model provider’s own key reaches the model provider and nothing else.

  • Cell values from a table’s own columns. No agent path reads a row out of a user table and puts it in a prompt except a read the model performed and you can see in the timeline. Note the boundary this does NOT cover: an Operate run’s sessions and slow-queries readings carry statement text and usernames from the engine’s own operational views, which is a different thing from a table’s contents and is documented as its own exposure above.

    A second boundary it does not cover, since #414: on MongoDB and Couchbase the schema reading reads your documents — 100 per collection — to learn what fields they have. No value out of them is put in a prompt, so the claim above holds as written; what is weaker than a reader might assume is the claim one line up in section 3, where a field name in the inventory is derived from your data rather than read from a catalog. Stated in both places on purpose: “no value leaves” and “nothing was read” are different promises, and only the first one is made here.

  • Cell values from a profile. The Assess workflow’s profile is aggregates only: counts of rows, present values, distinct values, and shape matches computed with count(CASE WHEN … LIKE …) inside the database. There is deliberately no min/max, because on a text column those return real values (src/lib/agent/table-profile.ts:1-27).

  • Rows a resumed run already read. They are named by artifact id and row count and are not delivered again (investigation.ts:306-313).

  • Your session. A run’s actor — session id and role — is written into its ledger for authorization and is never part of a prompt.

  • Anything at all when the model provider is not configured.


  • Not into the server log. streamText’s default onError writes the raw provider error to the console, and an APICallError carries requestBodyValues — the entire prompt. It is silenced deliberately in both places that call the SDK, and the error is read off the stream instead (investigation.ts:588-591, capability-probe.ts:36-40,257-259).
  • Not into the audit trail. Agent audit events carry the operation id, an agent:<role> actor, the outcome, a reason code, elapsed time and a correlation id — never the statement, the session identifier or the driver’s message (docs/SECURITY.md, control 3.5).
  • Not into the browser as provider text. A drive failure crosses to the rail as a label; the provider’s or driver’s own message stays in the server log, because none of them promises to keep a credential or a host name out of it (docs/AGENT.md, “A drive that dies before the loop”).

The agent is not the only thing that can reach a model. Four surfaces remain, over three routes — describe-schema is reached from two of them — each on an explicit user action, and none of them fences what it sends:

Feature Route What the browser sends Call site
Visual EXPLAIN’s AI explanation POST /api/ai/explain query, explainPlan, schemaContext, databaseType src/components/VisualExplain.tsx:486-497
Query safety dialog POST /api/ai/query-safety query, a filtered schemaContext, databaseType src/components/QuerySafetyDialog.tsx:167-171
Database documentation POST /api/ai/describe-schema A schema string built from table names, row counts and column definitions src/components/DatabaseDocs.tsx:61-68
Data Profiler’s AI summary POST /api/ai/describe-schema Per column: null percent, distinct count, min= and max= src/components/DataProfiler.tsx:84-107

That last row is the one to read carefully. /api/db/profile computes MIN(col::text) and MAX(col::text) per column (src/app/api/db/profile/route.ts:115-116), and the Data Profiler puts both into the context it sends for an AI summary. Those are real values out of your columns — the lexicographic first and last of each profiled column. It is the sharpest difference between the two profiling surfaces in this product: the agent’s profile_table was built so that no value can leave, and the legacy profiler’s AI summary sends two per column.

None of the four surfaces fires on its own: each is behind a control the user presses.


Terminal window
# Every message the run loop assembles
grep -n "messages.push\|toolResultMessage" src/lib/agent/investigation.ts
# Every place database-derived text is wrapped before it can reach a prompt
grep -rn "fenceUntrustedContent(" src/lib/agent/
# Every request that leaves for a model provider
grep -rn "streamText(" src/lib/agent/
# Every absolute URL in the server. Read the hits: most are hrefs and comments.
# Exactly one is a host a request can go to without you naming it - the OpenAI
# default base URL, reached only if you set LLM_PROVIDER=openai
# (src/lib/llm/utils/config.ts:23).
grep -rn "https://" src/

Related: docs/AGENT.md for behaviour, docs/AGENT_GUIDE.md for the surface, docs/SECURITY.md for controls 3.4 and 3.5.