Skip to content

feat(agent): ground plan mode on every engine, from the provider's own schema - #418

Merged
cevheri merged 1 commit into
mainfrom
feat/agent-plan-grounding
Aug 17, 2026
Merged

cevheri merged 1 commit into
mainfrom
feat/agent-plan-grounding

Conversation

@cevheri

@cevheri cevheri commented Aug 17, 2026

Copy link
Copy Markdown
Member

Closes #414.

Plan mode opened on every engine and was grounded on two. On the other nine it could name nothing, so
it correctly refused — while the sidebar, on the same screen and the same connection, listed the tables.
That gap was not a limit of the engines. It was a limit of where grounding was read FROM: the sidebar
calls the provider's getSchema(), which every provider implements, and the agent composed a catalog
STATEMENT whose CATALOG_PLANS carried postgres and sqlite and nothing else.

This adds the provider as a second grounding source, under its own audited operation, and leaves the
composed path exactly where it was.

The shape

A sixth operation descriptor, db.schema.read — R0/metadata-read, no required capabilities,
resourceCost: "heavy", and an input schema of z.strictObject({}) because getSchema() takes no
selector. It is modelled on db.operations.read, which is the precedent that makes this legitimate
rather than a side door: context-snapshot.ts opens by rejecting the obvious shortcut — "asking the
provider directly bypasses the M1 enforcement layer"
— and that objection is correct about a RAW
provider call and not about one made under an operation descriptor, which inspect_operations already
is. The reading goes through runAuditedAgentCall, so it meets the same mode check, repair ledger,
deadline admission, budget clamp, policy and audit stream as every other call, and an operator can deny
this one id without denying any other agent read.

heavy rather than light, unlike the curated read it copies: a provider schema read is N+1 round
trips on most engines and on MongoDB and Couchbase it samples documents to infer field names.
Calling it light would have been a false economy claim.

It acquires agent-operations, not agent-read-only. This is the one line the whole change rests
on. PROFILE_ACQUISITION sets requiresReadOnlyStatements: true for agent-read-only, and
factory.ts refuses that profile on every provider without queryReadOnly — which is all nine engines
in scope. The operations profile is the one that reaches them, for the same reason it lets Operate run
everywhere: this call sends no statement the engine has to plan.

PostgreSQL and SQLite keep the composed path, deliberately. Its reads are audited statement by
statement and appear in the run's own budget; it carries foreign keys, which the provider path cannot on
an engine that declares none; and SQLite's inventory is parsed out of stored DDL the provider does not
expose the same way. The asymmetry is argued in the module's own docblock rather than left for a reader
to infer.

Both modes, and that is not a widening. A mode gate on the capture would not have held:
holdSnapshotForConnection is keyed by connection and is filled and read by both modes, so a
plan-captured snapshot reaches an agent run on the same connection anyway. Gating would only have made
agent grounding depend on whether a plan run happened to go first, which is the #384 failure this
codebase has already fixed once. What agent mode gains is that operations — the one workflow that
genuinely runs everywhere — is now grounded everywhere instead of on two engines. What it does not gain
is reach: a schema-workflow agent run on these engines still fails at its first read, now pinned by a
test that asserts the capture succeeded first and the run still ends engine-unsupported.

The three questions the issue left open, answered

What a plan hands back on a queryLanguage: "json" engine. The deliverable is unchanged; two
CHECKS stopped making claims they had not earned. inspectAgentStatement reads every string as SQL, so
db.orders.aggregate([...]) leads with the word DB, INFO memory with INFO, and neither is in the
read allowlist — every correct MongoDB or Redis draft was already classified NON_READ_STATEMENT. It
was invisible only because no schema workflow was grounded there. validatePlanStatement now takes the
engine's language and declines to judge rather than judging wrongly: guardApplicable: false and
identifiers: { kind: "not-applicable" }, distinct from both existing branches, with its own wording on
all four surfaces that reach a user — including the headline, which would otherwise have read
"Statement drafted — not classified as a read" over a perfectly good aggregation.

Relations. An inventory with no foreign keys meant two different things and the ER block said only
one of them. It hedged between "that is how the schema is" and "the application enforces them", and on
MongoDB, Redis, LibreDB, Druid, ClickHouse and Couchbase both branches are wrong: the engine has no
foreign keys to declare. Driven by a new ProviderCapabilities.declaresForeignKeys, not an engine
check. B44's hedge is untouched — a role that cannot SEE keys is a third absence again.

Redis. The issue asked whether Redis counts as grounded "or as something narrower, and say which".
It is grounded, and the answer the live drive forced is that the inventory has to say what it is an
inventory OF. See below.

What the browser found that no gate could

Driven in Chrome against real local servers for all eleven engines, ledgers read under .workflow-data.
Three defects, all reproduced in the code before they were fixed.

The inventory called everything a "table" on every engine. A grounded Redis plan was handed a block
headed "17 table(s)" listing user:*, order:*, product:* and fourteen more — and then drafted
ZCARD user:* and KEYS user:*, treating a synthesized prefix group as an addressable key. The product
already had the vocabulary and the agent ignored it: ProviderLabels.entityName is implemented by every
provider and says Table, Collection, Datasource, Key Pattern, Key Prefix. It now reaches the packing,
the ER block and every plan rule, and nothing changes for the six SQL engines, pinned by a test rather
than by eye. A second capability, tablesAreDerivedGroupings (redis and libredb only), carries the part
a model cannot know because it is a fact about THIS codebase: those rows are groupings this server
derived by scanning a bounded part of the keyspace, they are not keys, and the list is what one reading
reached. No command is named or forbidden — a test asserts the rules contain no KEYS, no SCAN, no
"never use". Re-driven: the same objective that produced KEYS user:* now returns an honest refusal
naming what the inventory does not carry, and the other run reaches for a whole key with
HGETALL user:<id>.

A grounded plan run was told, in its own rules, that its inventory came "through the same read-only
catalog path the agent mode uses".
PLANNING_SNAPSHOT_PREFACE was made a 2x2 over
readBy x readVia; PLANNING_PROVENANCE, the second provenance sentence, was left one-dimensional.
So on the nine provider-path engines the model got that claim and the new preface's contradicting one in
the same conversation — the exact class of false self-description this issue exists to close, on an
engine where agent mode cannot take a read-only path at all. Now a 2x2, with all four strings pinned.

An environment failure lost a plan run on nine engines that previously could not lose one. Anything
thrown before the reading left — an unreachable host, a wrong password, a half-configured agentUser —
propagated out and ended the run internal, or worse showed "The agent cannot run on this database
engine"
on an engine where plan mode demonstrably works. captureFromProvider now converts
DatabaseError and ExecutionProfileError into an unavailable capture and the run continues and says
why, carrying the capture's own diagnosis. The composed path still propagates, unchanged and recorded
rather than quietly made to differ.

MongoDB's view defect, fixed and driven

getSchema() called estimatedDocumentCount() and collection.indexes() unguarded, and MongoDB
refuses both on a view, so ONE view aborted the whole schema read — for the sidebar as well as for the
agent. Fixed by reading the type listCollections() already reports rather than by filtering views out:
a view is an object the user created, and hiding it to keep three commands quiet answers "your view does
not exist". Verified live by creating a view on the seeded database: the sidebar lists six objects, the
capture reads all six, and the view arrives with its fields inferred, no indexes, and no row count —
absent rather than 0, because a view is not empty, its size is unknown.

Verification

Every gate run locally: format, lint (0 errors), typecheck, knip, tests/run-core.sh 297/297,
component groups 30/30, merged line coverage 100.00%, build. Changed files scanned for control bytes
and emoji.

Live, one engine at a time, outcome read off the screen. docs/AGENT_DEMO.md's engine table is updated
from these runs and from nothing else.

Left open, on purpose

  • LibreDB is still ungrounded, and the cause is now measured rather than guessed: lib.open() takes
    an exclusive file lock, and the agent profile builds a SECOND provider on a path the connection's
    ordinary provider already holds — so it fails from the moment anyone browses that connection. Filed
    with the reproduction rather than fixed.
  • A Redis plan can still name KEYS. Grounding is working and the draft is valid Redis; what is
    unresolved is whether one sentence about operational cost belongs in the rules, against the standing
    argument that a rule banning a command by name is engine trivia that goes stale. Filed with both run
    ids and both verbatim outcomes, undecided.
  • authSource has no connection-form support. Filed; out of scope here.

…n schema

Plan mode opened on every engine and was grounded on two. On the other nine it could
name nothing, so it correctly refused - while the sidebar, on the same screen and the
same connection, listed the tables. That was never a limit of the engines. It was a
limit of where grounding was read FROM: the sidebar calls the provider's getSchema(),
which every provider implements, and the agent composed a catalog STATEMENT whose
CATALOG_PLANS carried postgres and sqlite and nothing else.

This adds the provider as a second grounding source under its own audited operation,
and leaves the composed path exactly where it was.

db.schema.read is a sixth operation descriptor - R0/metadata-read, no required
capabilities, an empty strict input because getSchema() takes no selector, and
resourceCost heavy because it is N+1 round trips and, on MongoDB and Couchbase,
samples documents to infer field names. It is modelled on db.operations.read, which
is the precedent that makes it legitimate rather than a side door: context-snapshot.ts
rejects asking the provider directly because that bypasses M1, and the objection is
correct about a RAW call and not about one made under an operation descriptor. The
read goes through runAuditedAgentCall, so it meets the same mode check, repair ledger,
deadline, budget, policy and audit stream as every other call.

It acquires agent-operations rather than agent-read-only, and that is the line the
whole change rests on: the read-only profile requires queryReadOnly, which none of the
nine engines has, so the factory would refuse it before the provider was ever asked.

PostgreSQL and SQLite keep the composed path deliberately - its reads are audited
statement by statement, it carries foreign keys, and SQLite's inventory is parsed out
of stored DDL the provider does not expose the same way.

Both modes take the shared capture. A mode gate would not have held: the process
snapshot hold is keyed by connection and is filled and read by both, so gating would
only have made agent grounding depend on whether a plan run went first - the #384
failure this codebase has already fixed once. Agent mode gains no reach: a schema
workflow there still fails at its first read, pinned by a test that asserts the capture
succeeded first.

The three questions the issue left open are answered. validatePlanStatement now takes
the engine's language and declines to judge rather than judging wrongly - every correct
MongoDB or Redis draft was already classified NON_READ_STATEMENT, invisible only
because no schema workflow was grounded there. The ER block stops hedging over foreign
keys on six engines that cannot declare one, driven by a capability rather than an
engine check. And Redis is grounded, with the answer that the inventory has to say what
it is an inventory OF.

Three defects came from driving eleven engines in a browser, not from a gate.

The inventory called everything a table. A grounded Redis plan was handed a block
headed "17 table(s)" listing user:*, order:* and fifteen more, and then drafted
ZCARD user:* - treating a synthesized prefix group as an addressable key. The product
already had the vocabulary and the agent ignored it: ProviderLabels.entityName says
Table, Collection, Datasource, Key Pattern, Key Prefix. It now reaches the packing, the
relations block, every plan rule, the timeline and the rail, and nothing changes for the
six SQL engines. A second capability carries the part a model cannot know because it is
a fact about this codebase: those rows are groupings this server derived from a bounded
scan, they are not keys, and the list is what one reading reached.

A grounded plan run was told, in its own rules, that its inventory came through the same
read-only catalog path agent mode uses. The preface was made a 2x2 over readBy x readVia
and this second provenance sentence was left one-dimensional, so on nine engines the
model got that claim and its contradiction in one conversation - on engines where agent
mode cannot take a read-only path at all.

An environment failure lost a plan run on nine engines that previously could not lose
one. A capture that threw before the reading left ended the run internal, or showed "the
agent cannot run on this database engine" where plan mode demonstrably works. It now
converts to an unavailable capture and the run continues and says why.

MongoDB's getSchema() called estimatedDocumentCount() and indexes() unguarded and
MongoDB refuses both on a view, so one view aborted the whole schema read for the
sidebar as well as the agent. Fixed by reading the type listCollections() already
reports rather than hiding views, and verified live: six objects listed, the view with
its fields inferred, no indexes, and no row count - absent rather than zero, because a
view is not empty, its size is unknown.

Driven live on 2026-08-17 against real local servers, one engine at a time. Ten of
eleven grounded and drafting in their own language: PostgreSQL 22 tables, SQLite 8,
MySQL 3, Oracle 3, SQL Server 3 with correct TOP 10, MongoDB 5 collections then 6 with
a view, ClickHouse 5, Couchbase 3 with SQL++, Druid 2 datasources, Redis 17 real key
prefixes. LibreDB is the exception and the cause is measured: lib.open() takes an
exclusive file lock and the grounding read builds a second provider on a path the
connection's own provider already holds.

Left open, filed rather than hidden: LibreDB's lock, a Redis plan that can still name
KEYS, and MongoDB's missing authSource.

Closes #414.
@sonarqubecloud

Copy link
Copy Markdown

@cevheri
cevheri merged commit a7fbc95 into main Aug 17, 2026
23 checks passed
@cevheri
cevheri deleted the feat/agent-plan-grounding branch August 17, 2026 21:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(agent): ground plan mode on every engine, from the provider's own schema

1 participant