Repository navigation
feat(agent): ground plan mode on every engine, from the provider's own schema - #418
Merged
Merged
Conversation
…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.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



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 catalogSTATEMENT whose
CATALOG_PLANScarriedpostgresandsqliteand 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 ofz.strictObject({})becausegetSchema()takes noselector. It is modelled on
db.operations.read, which is the precedent that makes this legitimaterather than a side door:
context-snapshot.tsopens by rejecting the obvious shortcut — "asking theprovider 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_operationsalreadyis. 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.
heavyrather thanlight, unlike the curated read it copies: a provider schema read is N+1 roundtrips 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, notagent-read-only. This is the one line the whole change restson.
PROFILE_ACQUISITIONsetsrequiresReadOnlyStatements: trueforagent-read-only, andfactory.tsrefuses that profile on every provider withoutqueryReadOnly— which is all nine enginesin 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:
holdSnapshotForConnectionis keyed by connection and is filled and read by both modes, so aplan-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 thatgenuinely 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; twoCHECKS stopped making claims they had not earned.
inspectAgentStatementreads every string as SQL, sodb.orders.aggregate([...])leads with the wordDB,INFO memorywithINFO, and neither is in theread allowlist — every correct MongoDB or Redis draft was already classified
NON_READ_STATEMENT. Itwas invisible only because no schema workflow was grounded there.
validatePlanStatementnow takes theengine's language and declines to judge rather than judging wrongly:
guardApplicable: falseandidentifiers: { kind: "not-applicable" }, distinct from both existing branches, with its own wording onall 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 enginecheck. 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 draftedZCARD user:*andKEYS user:*, treating a synthesized prefix group as an addressable key. The productalready had the vocabulary and the agent ignored it:
ProviderLabels.entityNameis implemented by everyprovider 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 parta 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, noSCAN, no"never use". Re-driven: the same objective that produced
KEYS user:*now returns an honest refusalnaming 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_PREFACEwas made a 2x2 overreadByxreadVia;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 databaseengine" on an engine where plan mode demonstrably works.
captureFromProvidernow convertsDatabaseErrorandExecutionProfileErrorinto an unavailable capture and the run continues and sayswhy, 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()calledestimatedDocumentCount()andcollection.indexes()unguarded, and MongoDBrefuses 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.sh297/297,component groups 30/30, merged line coverage 100.00%,
build. Changed files scanned for control bytesand emoji.
Live, one engine at a time, outcome read off the screen.
docs/AGENT_DEMO.md's engine table is updatedfrom these runs and from nothing else.
Left open, on purpose
lib.open()takesan 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.
KEYS. Grounding is working and the draft is valid Redis; what isunresolved 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.
authSourcehas no connection-form support. Filed; out of scope here.