# Architecture

This is how a tool call actually reaches your database, and why the
non-negotiable rule — no tool ever accepts raw SQL — holds structurally,
not just by convention.

## The one path every call takes

```
Agent → typed MCP tool call
      → declarative plan (QueryPlan | MutationPlan | ProcedureCallPlan)
      → PolicyEngine validation (exposure, masking, identity, verb)
      → driver translates plan → parameterized SQL, in its own dialect
      → your database
      → AuditLog entry (always, regardless of outcome)
```

There is no branch of this path where a string the agent supplied is
concatenated into a query. The plan types have no field that could hold
one — `QueryPlan.Filters` is a typed list of `{column, op, value}`
triples with `op` restricted to a fixed enum (`eq`, `neq`, `in`, `gt`,
`gte`, `lt`, `lte`, `ilike`, `is_null`, `not_null`), not a WHERE-clause
fragment. This is what makes SQL injection structurally impossible
rather than merely filtered against, and what makes it impossible for a
guardrail to be routed around — masking and permission checks happen on
the plan, before any driver sees it, so there's no later stage that
could "forget" to apply them.

## Introspection → SchemaModel

At startup (and on every SIGHUP), each connector's driver introspects
its target database's real catalog — `information_schema` for
Postgres/MySQL, `sys.*` views for SQL Server — and normalizes it into an
engine-agnostic `schema.SchemaModel`: tables, columns (with a normalized
logical type: integer/float/text/boolean/timestamp/uuid/unknown, not the
engine's native type name), primary keys, foreign keys, and stored
procedures with their parameters. Table and column comments (`COMMENT
ON` in Postgres, similar mechanisms elsewhere) are captured too — they
feed directly into generated tool descriptions, so an agent sees your
actual schema documentation, not just column names.

## Policy → PolicyEngine

A connector's stored policy (see [policy.md](policy.md)) plus its
introspected `SchemaModel` build a `policy.Engine` — the single source
of truth every downstream component asks, never bypasses: is this table
exposed, is this column masked, is this identity allowed to see or write
to this table, what's the row cap, what's the query timeout. Nothing
about visibility or permission is decided anywhere else.

## Tool generation

The tool generator walks the exposed tables and procedures in the
`SchemaModel`, filtered through the `PolicyEngine`, and emits one MCP
tool definition per operation actually enabled: `get_<table>_by_pk`,
`search_<table>`, `aggregate_<table>` always (for every exposed table);
`create_<table>`, `update_<table>_by_pk`, `soft_delete_<table>_by_pk`
only for tables with that specific mutation enabled; one tool per
exposed procedure; and a global `schema_overview`. Each tool's JSON
Schema is built from the table's *visible* columns only — a masked
column has no field in the schema to put a value into, and doesn't
appear in the tool's description either.

**Known limitation, not accidental**: tool *definitions* are generated
once per connector, the same for every caller — an identity blocked from
a table still sees that table's tools in `tools/list` with a full
schema; only *calling* them is blocked, at the plan-validation step
below. True per-identity tool visibility would mean dynamic, per-session
tool registration — a materially bigger change, tracked as a known gap
rather than silently left unaddressed.

## Execution: the choke points

Every read, write or procedure call follows the same sequence regardless of
which tool triggered it:

1. Resolve the verified identity (if `auth.enabled`) from the request.
2. Check `PolicyEngine.IdentityAllowed`/`IdentityReadOnly`/
   `IdentityCanMutate` — blocked here never reaches step 3.
3. Build the plan from the agent's typed arguments, coercing JSON values
   to each column's logical type.
4. Validate the plan structurally — table/columns exist and are visible,
   ops are in the enum, mutation columns are writable and not masked,
   `update`/`delete` carry every primary-key column as an equality
   filter, `limit` is capped to the policy's row cap.
5. Hand the validated plan to the connector's driver.
6. Write exactly one audit entry — allowed, blocked, or errored — no
   matter which of the above steps stopped the call.

A blocked or errored call never reaches the database at all; a masked
value is never in reach of any of these steps to begin with, since it
was never in the plan.

## Drivers

`driver.Driver` = `Introspector` + `Executor` + `Mutator` +
`ProcedureCaller`. PostgreSQL, MySQL and SQL Server support ships with the
product; each connector translates the same plan types into that
engine's own parameterized SQL dialect (`$1, $2...` for Postgres, `?`
for MySQL, `@p1...`/`sql.Named` for SQL Server). Mutations run inside a
transaction: the driver checks `AffectedRows` against the policy's
`max_affected_rows` **before** committing, rolling back rather than
committing an over-broad write.

The published distribution includes PostgreSQL, MySQL and SQL Server support.
Select the connector type in `config.yaml`; no separate driver installation is
required.

## Multi-connector

One `runku serve` process can protect several databases at once.
Runku exposes one independent MCP endpoint per connector, at that connector's
own `path` (defaulting to
`/mcp/<id>`) — tool names never need a connector prefix, since each
connector lives at a different URL. Each connector gets its own driver
connection, its own introspected `SchemaModel`, its own `PolicyEngine`,
and its own generated tool set, entirely independently of the others.

## The admin store

Roles, identities, API key hashes, role bindings, and every connector's
policy live in the admin store — a small database of its own
(SQLite or PostgreSQL), completely separate from both the databases
Runku protects and from the audit trail's own storage. `runku serve`
reads from it at startup and again on SIGHUP; API key lookups are live,
per-request, with no caching layer to invalidate. See
[auth-and-permissions.md](auth-and-permissions.md) for the full
roles/identities model this database holds.

Both SQLite and PostgreSQL admin stores are included in the published
distribution. PostgreSQL is recommended for production and multi-instance
operation; SQLite is appropriate for local or small single-instance setups.

## Audit and the outbox pattern

Every call produces one structured entry — never the actual row data
returned or a masked value, only what was asked and what was decided.
It's always written to stdout. Two additional sinks are optional and
independent: a local SQLite buffer (powers `/reports/*` and the portal)
and a rotated on-disk file. From the SQLite buffer, an optional
`Shipper` periodically POSTs unshipped batches to an external
destination and deletes them **only after a confirmed successful
delivery** — an outbox: a failing destination never loses data, it just
keeps accumulating locally until shipping succeeds. See
[configuration.md](configuration.md#audit) for how to configure a durable
destination.
