Engineering NotesDatabase connections and OwlDB
Two SQL executors, one set of checks and one result format

OwlAI has two ways to run SQL against a customer database. On a direct connection (one where an administrator typed in the connection details), OwlAI's built-in executor connects to the database. On an OwlDB connection, OwlDB runs the SQL. An executor is the component that sends SQL to the database and brings the result back. If the two return results in slightly different ways, the model reads the difference as a difference in the data. Send SQL with LIMIT 200 against a table of 2,000 rows, and if only one executor reports "more rows," the model answers "200 in total" for databases on the other. If one executor drops the time zone offset from timestamps, a 10 a.m. order in Seoul shows up in the answer as "01:00."
So three things exist only once, however many executors there are. The permission and read-only checks run once, before an executor is chosen. Whether a result was truncated, what cell values look like, and how rejections are reported all follow one definition outside the executors. Where the two paths deliberately behave differently, those differences live in one place: the connection source definition. This post explains what each of the three decides, and why none of them lives inside an executor.
The checks run before an executor is chosen
When the model calls the SQL execution tool, these checks run in order. Only then is an executor chosen.
- If the user scoped the turn (one question through to its answer) by mentioning a database, reject calls to any other database.
- Check whether the user's role may use the database. The unit is the database; which tables inside it can be read is decided by the database account's privileges (“Per-role database accounts enforce table permissions, not SQL checks”).
- Check that there is a single statement and that it starts with
SELECTorWITH. - Set the row cap for this call. If the model gives no value, it is 500 rows, and a value above the installation setting
dbConnection.maxRowLimitis lowered to that setting.
Only a call that passes all four reaches an executor. The check that reads the statement with the engine's grammar to confirm it is a read needs nothing but the engine name, so the built-in executor runs it just before sending to the database, and an OwlDB connection runs the same check before sending its request to OwlDB. The built-in executor then adds a read-only session and a time budget (“Read-only and query cancellation have to reach the database server”). The request on an OwlDB connection carries the database identifier, the account identifier for the role, the SQL and the row cap (“OwlDB connections hold a database ID instead of credentials”).
Each executor could have its own checks instead. Whoever works on an executor could then change rules with only that path in view. The cost is that every change to the mention-scope or role-permission rules has to be made in two places, and a gap left by missing one shows up only on databases connected through that source. We made the same call inside the built-in executor. It runs five engines (PostgreSQL, MariaDB, MySQL, Oracle and Tibero), and each engine fills in only connecting, running a statement, cancelling and classifying errors. The statement check, attaching the row cap and converting cell values sit in one place all five engines share.
The tool decides once whether a result was truncated
The model's worst misreading is taking a truncated result for the whole thing. So the "result was truncated" flag is set by the SQL execution tool alone, not by the executor. There are two grounds, and either one makes the result truncated.
- The executor read one row past the row cap, and that row arrived. That proves more rows remain, and the extra row is dropped before the model sees the result.
- The SQL's own limit clause (
LIMIT,FETCH FIRST) was reached. The database stops at that limit, so reading one more row cannot tell.
The LIMIT 200 case above is the second one. The executor receives 200 rows, has no further row to read, and cannot say the result was cut off. The tool reads the SQL with the engine's grammar, sees a limit clause of 200 and 200 rows received, and sets the flag. The exception is an aggregate-only SELECT without GROUP BY. SELECT COUNT(*) … FETCH FIRST 1 ROW ONLY reaches its limit of 1, but no row can follow, so it is not treated as truncated. When the result's total size exceeds dbConnection.resultMaxBytes and the executor stops receiving partway, the same flag reports it.
The model gets one thing: whether the result was truncated. Which cap was hit, and what its value was, are left out. A second number next to the rows is easy for the model to read as the total count. When the model gets a truncated result and needs the total, it runs COUNT(*) separately. Which cap was hit goes to the operator log.
On an OwlDB connection, this check still lives in OwlAI. OwlDB returns results up to the row cap in the request and reports whether rows remained, and OwlAI combines that signal with the SQL's limit clause in the same way. If the check lived in the executor, OwlDB would have to read limit clauses too, and the moment the two products read SQL differently, the same SQL would get a different truncation flag depending on the source.
Cell values follow one rule set outside the executors
Driver values have different types on every engine. What the model receives is converted by a single set of rules.
| Value | What the model receives |
|---|---|
| Dates and times | A string. Values with a time zone are converted to UTC and keep the offset (+00:00) |
| Integral decimals | An integer that keeps every digit |
| Fractional decimals | A floating-point number |
| Text LOBs | The first 4,000 characters plus a truncation marker with the total length |
| Binary LOBs | Size only |
| Binary values | Hexadecimal up to 64 bytes, size only beyond that |
| Types without a rule | A string, with one warning logged per type |
Each rule guards against a specific mistake. Drop the offset from a time-zone-aware value, and the model and the result table read a UTC time as the user's wall-clock time. With the offset kept, the model converts it into the user's time zone when it answers. Read a Tibero NUMBER without precision as a floating-point number, and values above 2^53 and 38-digit values change in their last digits. If those values are order or account numbers, the model runs its next query with a number that does not exist. If one path returns a CLOB as its text and the other as the driver's object notation, the model flips between "the content is there" and "it is empty" for the same column.
The warning for types without a rule is there to catch silent conversions. If a value turned into a string was an object address, odd characters appear on screen, and without a log no one notices. Results on an OwlDB connection arrive as values under the same rules, so the model reads the same shapes without knowing which source the database came from.
Rejection reasons and execution records do not depend on the source
Rejections split by whether the model can fix them by changing the SQL.
- When the database rejects the statement (a syntax error, a missing column), its message is returned as is, because what needs fixing is the SQL.
- When the database reports missing privileges, the result is a table access rejection. The reason is the same whether the account belongs to a direct connection or is OwlDB's per-role account (“Per-role database accounts enforce table permissions, not SQL checks”).
- When the connection fails, only the database and the failure category (connection refused, authentication failed and so on) are included. The driver's raw message contains internal network addresses and details, so it is left out. On an OwlDB connection, a request returned for an infrastructure reason such as node state is not shown to the model; OwlAI sends it again (“AI queries pick their database node at run time, not at registration”).
- An execution that ran past its time budget and one the user stopped come back with different reasons.
Every execution, whether it succeeded or was rejected, adds one line to the SQL execution history: who sent which SQL to which database, the tables the SQL referred to, the number of rows received if it succeeded, and the outcome (succeeded, rejected or failed). Query result values are not stored in this record. That keeps the record from becoming another path for data to leak. Administrators see the same fields on the same screen regardless of connection source.
Deliberate differences live in the connection source definition
The connection source records where a connection was created. There are four: direct entry, Supabase, Amazon RDS and OwlDB. It is set when the connection is registered and never changes. The rules that differ by source sit in one table.
| Rule | Direct entry | Supabase | Amazon RDS | OwlDB |
|---|---|---|---|---|
| Roles can get table-level permissions | No | No | No | Yes |
| Can go through an SSH tunnel | Yes | No | Yes | No |
| Engine, host, port and TLS can be changed when editing | Yes | No | No | No |
| Who runs the SQL | Built-in executor | Built-in executor | Built-in executor | OwlDB |
The registration screen, the API, the place where an executor is opened and role permissions all read this table, so a rule never applies one way on screen and another way in the API. Try to grant table-level permissions on a direct-entry connection and the API rejects it with RBAC_DB_TABLE_SCOPE_UNSUPPORTED; try to change the host of a Supabase connection and it rejects with DB_CONNECTION_SOURCE_LOCKED. Adding a source means filling in one row of this table, and adding a rule means adding a column and deciding its value for all four sources.
Table-level permissions exist only on OwlDB connections because of what enforces the boundary. On an OwlDB connection, the SELECT privileges of the per-role database account block tables. Showing the same screen on a direct connection would mean blocking tables with a check that extracts table names from the SQL, and that check disagrees with how the engine resolves names (“Per-role database accounts enforce table permissions, not SQL checks”). With the same screen, administrators would believe a table was blocked. The pre-execution check for heavy SQL (“Heavy SQL is judged by OwlDB, which knows the database's state, not by a cost threshold”) and the choice of execution node (“AI queries pick their database node at run time, not at registration”) are OwlDB-only for the same reason: both need database state that a single connection cannot see.
Design choices
We put the checks in one place in front of the executors instead of in each executor. With checks in each executor, a rule fixed on one side goes missing on the other, and the gap shows up only on databases of that source. The cost is that even a rule only one executor needs has to branch on the source at the common check.
We put the truncation check in the SQL execution tool instead of the executor. The SQL's limit clause is evidence the executor cannot see, and having each executor read it would give the same SQL a different truncation flag depending on the source. The cost is that the tool has to know each engine's limit clause syntax.
Cell values follow one rule set, because what the model sees is the result, not the executor. The cost is that some information the engine has never reaches the model. A time-zone-aware value arrives converted to UTC instead of with its original offset, and a LOB longer than 4,000 characters arrives as its beginning only.
We gathered the per-source differences in one place, the source definition. Imitating table-level permissions or pre-execution cost checks on direct connections would make the screen look the same while the level of enforcement differs. Direct connections show only what they can enforce.
If you have only a few databases and each role's access splits cleanly by database, a direct connection is enough. If you need different table permissions within the same database, or AI queries share a database with production work, connect that database through OwlDB. You can mix both methods in one installation, and the results the model receives keep the same format.
What problem does this solve?
More in Database connections and OwlDB

Engineering Notes · Database connections and OwlDB
The bastion gets one key line that only forwards to the database, not an account
When a production database sits in a private network behind a bastion host (an SSH server that leads into that…

Engineering Notes · Database connections and OwlDB
Certificate errors are fixed with the right CA and the right name, not by turning off verification
When OwlAI runs on a corporate network and connects over TLS to a cloud LLM or a customer database, certificate errors come from two places.

Engineering Notes · Database connections and OwlDB
Customer RDS lists are read with a 60-minute role, not an access key
To connect a customer's Amazon RDS or Aurora database from OwlAI SaaS, OwlAI needs the endpoint, port and engi…
