Skip to content

What a plan-mode model is told about a LibreDB file

The grounding read is bounded by the provider itself, and the plan rules say outright that the rows are groupings derived from one scan.

The way to mislead a model pointed at an embedded key-value database is to hand it a table list. Open a .libredb connection in Studio and the sidebar shows rows called users:*, articles:*, config:*. They look like objects the engine declares. They are not. They are groupings this server derived by scanning the keyspace once and cutting each key at its first colon.

So a plan run on this engine gets two things before the model’s first turn: a reading of the schema, and a sentence saying what that reading is worth.

Why AUTO mode does not open on this engine

Agent AUTO mode - the metered run that holds tools and executes statements of its own - does not open here at all.

The reason is not a policy list. A profiled acquisition is refused unless the provider exposes queryReadOnly, and that method exists on exactly three providers: providers/sql/postgres.ts, providers/sql/sqlite.ts and providers/sql/duckdb/. AGENT_EXECUTION_ENGINES is ['postgres','sqlite','duckdb']. The read-only guarantee is database-native on those three - a read-only transaction, PRAGMA query_only, a READ_ONLY handle - and the LibreDB provider implements no such method. Grep for queryReadOnly under the LibreDB provider and there is no hit. An agent AUTO run on a LibreDB connection ends engine-unsupported. That is the whole story of AUTO mode on this engine, and it is the same sentence the feature pages publish for every engine outside those three.

PLAN mode is a different thing and opens on every connection, this one included. It is toolless. It executes nothing. It reads context, then drafts a statement and hands it to a person. Nothing it produces reaches the driver unless someone types Run.

The two are easy to blur. AUTO does not open here. PLAN does, and it is grounded.

What the grounding read covers, and its bound

Since 2026-08-15 the server reads the connection’s schema through the provider before the model’s first turn, so a plan run is not guessing at names. The read is bounded, and the bound is the provider’s own: MongoDB stops at 200 collections, Redis scans 1000 keys, LibreDB 10000.

On this engine that number is LIBREDB_MAX_KEY_SCAN, and the scan is one half-open range over the entire keyspace:

kv.range('', '\u{10FFFF}')   // stops after 10000 keys

Each key is cut before its first : and the piece becomes a group named user:*; a key with no colon becomes a single-key group under its own name. Keys in the reserved internal namespace are skipped through the package’s own isReservedKey predicate, so the catalog’s own bookkeeping never reaches the model as a pseudo-table. Where the file carries a catalog entry for a namespace, the group is upgraded to a faithful view: a relational namespace shows its real declared columns with the primary key marked, a document namespace shows generic id and document columns, and an uncataloged raw-kv namespace shows key and value.

The bound is not decoration. Prefix groups that appear only past the ten-thousandth key are not in the reading, and the per-group counts are capped with it. The monitoring Tables panel refuses outright above the cap with LIBREDB_TABLE_STATS_TRUNCATED rather than publish counts short by an unknown amount. The plan run’s inventory takes the other route - it keeps the namespaces the scan reached and says so in words, because a list of namespaces is not a count.

One more thing had to be true before any of this worked. lib.open({ path }) takes an exclusive <path>.lock sidecar, so a .libredb file admits exactly one handle and a second open throws LOCKED. The grounding read used to be a second open, and it lost: a ConnectionError becomes an unavailable capture rather than a failure, so every plan run on a LibreDB connection was silently ungrounded from the moment anyone browsed the connection in the sidebar. The capture now calls findOpenSingleWriterProvider first and borrows the live handle, keyed by resolved file path rather than connection id. Two bounds keep the isolation invariant: only agent-operations borrows, since it sends no statement of the model’s, and no borrow happens for a connection that configures an agentUser, because a reuse cannot substitute one principal for another.

Telling the model what its inventory is

A grounded list of users:* and articles:* is still a trap if the model reads those rows as tables. So the capability that produced them is stated to it.

tablesAreDerivedGroupings is true on exactly two providers - Redis and LibreDB - and where it is true the plan rules carry one sentence saying three things: what the rows are (groupings this server derived from a bounded scan), what a statement may name instead, and that the list is one reading’s reach rather than the database’s contents. The plan prompt’s noun for a LibreDB row is Key Prefix, not table.

The second half of that sentence earns its place. Measured on 2026-08-22 in plan mode against the embedded sample, objective list every entry under the users prefix and read one user by key: the run was grounded - three prefixes captured, articles:*, config:*, users:* - and drafted

GET users:*

dispatchCommand gives get exactly one meaning, kv.get(parts[1]), an exact-key lookup with no glob of any kind. That command answers zero rows and no error, which on a key-value store reads as nothing is stored there rather than as a mistake. So the plan contract now names all five verbs - get, put, delete, prefix, range - and states that a key is matched exactly, with no wildcard. Every entry under a users:* row is reached with prefix users:, which is the same form the schema explorer emits when a person clicks that row.

What the audit trail does and does not cover on this engine

The AUTO pipeline is often described as the safety story: every statement passes a policy decision, an audit event and budget accounting before the driver is touched. None of that applies here. Because no agent statement ever executes on this engine, the agent’s audited execution pipeline never applies, and the ordinary query history - admin-only, like every engine’s - is the whole trail. Commands you run land in it the way any engine’s statements do. There is no LibreDB-specific agent audit path, and this site does not claim one.

That is a narrower guarantee than the AUTO engines carry, and it is narrower in a specific direction: there is less to audit because there is less that runs. The security page publishes the same boundary from the other side.

Two absences reinforce it. getActiveSessions() refuses with LIBREDB_ACTIVE_SESSIONS_REFUSAL - the file is opened inside this server’s own process, and the <path>.lock holds only libredb-lock, a pid, a hostname and a nonce, with no user, statement or start time to build a session row from. And runMaintenance(type) always throws, with supportsMaintenance: false and maintenanceOperations: []. There is no maintenance surface for a drafted plan to aim at.

Who runs the drafted command

A person does. That is the entire handover, and on this engine it has no alternative branch.

What the drafted command has to survive is the human step, so a few grammar facts are worth carrying. range is half-open: [start, end). A JSON value in a put must be wrapped in single quotes, because the tokenizer treats bare double quotes as token quoting and would strip them, storing invalid JSON:

put user:3 '{"name":"Grace","age":45}'

There is no transaction verb in the grammar, so supportsTransactions is false and no sandbox is offered around what you run. There is no UPDATE ... SET either, so supportsInlineRowEdit is false and the results grid will not edit a value back for you. What the UI does do is watch what you ran: schemaRefreshPattern is \b(put|delete)\b, so a write refreshes the key groupings the next reading will be built from.