Private beta -- real-time chain data is still being wired up.
Back to blog

August 25, 2026 · 7 min read

How Chainlake's Architecture Keeps an AI Agent Honest

architectureai-agentpermissions

Most "AI-native" analytics tools have the same weak point: you ask a question, you get an answer, and you have no real way to check how it got there. That's fine for a casual question. It's not fine for anything you're going to act on. Chainlake was built around a different premise -- that the agent's reasoning should be a first-class, inspectable part of the product, not a side effect of a chat log.

Getting there wasn't really an AI problem. It was a data-modeling and permissions problem. This post is about the architecture underneath, not the model prompts on top of it.

One data model, three surfaces

There are three main workspaces: a data explorer for browsing schema and writing SQL by hand, an AI agent for asking questions in plain English, and a dashboard surface for saved charts. The easy way to build this is as three separate features that happen to share a login. That's not what happened here, on purpose.

Every query that runs anywhere in the product -- whether a person typed it or the agent generated it -- lands in the same underlying record: the SQL text, who ran it, what it was scoped against, how long it took, how many rows came back, whether it succeeded. Every chart, likewise, points back to the query that produced it, whether a person built it by hand or the agent rendered it mid-conversation. The practical effect is that the agent isn't a separate, parallel system with its own rules -- it's another client of the same query and charting infrastructure everything else uses. If the agent's output looks wrong, you can open the exact query it ran in the same editor a person would use to write one by hand, because it's genuinely the same object, not a reconstruction.

This sounds like a small decision. It has a large downstream effect: it means "audit the agent" isn't a separate feature that has to be purpose-built and kept in sync. It's a query and rendering pipeline that only knows how to record what happened, regardless of who or what triggered it.

Governing what the agent is allowed to do

An agent that can write and execute its own SQL is, structurally, an agent that could run anything -- unless something stops it. The approach here is a narrow, explicit boundary rather than a system prompt asking nicely:

  • Read-only, always. The agent's SQL execution path is a whitelist of read-only statement shapes. DDL and DML are rejected before they ever reach a database connection, not caught after the fact.
  • Bounded by default. Every query the agent runs carries a timeout and a scan-size ceiling. Hitting either produces a clear, visible failure state -- "this was cut off because it scanned too much data" -- instead of silently truncated or stalled output.
  • Every tool call is logged, not just the ones that produced something. Schema lookups, table listings, failed queries -- all of it lands in an audit trail keyed to the session that produced it, alongside the successful ones. A session where the agent tried something, got refused, and tried a different approach is exactly the kind of thing you want to be able to see later, not just infer from the final answer.

None of this requires trusting the model to behave. The boundary is enforced by the execution layer the agent's tool calls pass through, the same way it would be enforced against a person typing SQL directly.

Permissions the agent can't work around

The part that matters most, and is easiest to get wrong, is that the agent runs under the same permission system as everything else -- not a service account with broader access "for convenience." The data model underneath is a straightforward hierarchy: a user belongs to an organization, an organization contains projects, and a permissions table governs what scope -- personal, project-wide, organization-wide -- any given resource sits at.

When the agent resolves what a person is asking about, it resolves connections, tables, and rows through that same permission check a manual query would go through. There's no elevated path. A person testing this with an account that only has partial table access should see the agent behave identically to how they'd behave running SQL by hand against the same restricted account -- because under the hood, it's the same authorization check, not a parallel one that could drift out of sync over time.

This also shapes how sharing works. A derived resource -- a saved query built on top of a connection, say -- can never end up with a broader scope than the resource it's derived from. That invariant has to hold structurally, or "share this dashboard with my team" could quietly leak access to a data source the team was never granted. Enforcing it as an invariant in the permission layer, rather than as a check scattered across every feature that creates derived resources, is what keeps that guarantee from eroding as more features get added on top.

Splitting the backend by what it actually does

The system underneath these three surfaces splits into three distinct services, not because microservices are fashionable, but because the pieces genuinely have different operational shapes:

The frontend is a single-page application talking to an API layer over HTTP -- nothing unusual here.

The API/gateway layer runs as a long-lived process, not as short-lived edge functions. That choice matters for one specific reason: real-time features. Push notifications for things like organization membership changes or permission approvals use Postgres's LISTEN/NOTIFY mechanism, streamed to the browser over server-sent events. That requires a persistent database connection that stays subscribed -- which is straightforward for a long-running process and awkward-to-impossible for a request-scoped edge function. The rest of the gateway's database traffic goes through a connection pooler in transaction-pooling mode for efficiency, but the one connection doing LISTEN deliberately bypasses the pooler entirely and talks directly to Postgres, because a pooled connection in transaction mode can get reassigned to a different backend connection between transactions -- and a LISTEN subscription registered on one backend connection doesn't reliably follow you to whichever one you get handed next. Two different connection strategies for two different jobs, in the same service, on purpose.

The query execution layer is a separate, isolated service whose only job is talking to external databases -- the actual customer-connected sources being queried, not the product's own storage. Keeping this as its own service means the blast radius of "something goes wrong talking to a customer's database" is contained, and it means adding support for a new database type is a matter of adding a driver to this one service rather than touching the API layer or the frontend at all. Not every driver ships by default, either -- there's a real, deliberate difference between "this database type is theoretically supported by the code" and "the actual driver is installed in the production image." Advertising the former as the latter is exactly the kind of gap that looks fine until someone actually tries to connect and it fails.

Why any of this is worth writing down

None of these are exotic techniques. Shared data models, explicit permission invariants, connection-strategy choices driven by what a feature actually needs -- this is fairly ordinary backend design. The thing worth naming is that "the agent's reasoning is inspectable" and "the agent can't exceed your permissions" aren't features you add later. They're consequences of decisions made at the data-model and service-boundary level, before any model prompt gets written. If those decisions are wrong, no amount of prompting fixes it after the fact.

That's really the throughline for how this gets built: figure out what has to be true structurally, then let the product behavior fall out of that -- rather than getting a demo working first and trying to retrofit trust into it afterward.