From cce44283034ed7a486a11238514665caff3d4f4d Mon Sep 17 00:00:00 2001 From: koraysrn Date: Tue, 1 Sep 2026 14:42:58 +0300 Subject: [PATCH 1/3] fix(agent): aggregate the PostgreSQL grounding column read per table (B52) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composed column capture projected one row per column, so a stock image's own wide catalog exceeded maxResultRows: 200 and the run grounded with no inventory on TimescaleDB, Cloudberry and AlloyDB Omni. Aggregate the columns per table so the projection is one row per object, symmetric with SQLite, and parse the aggregated array in buildPostgresTables. Live-verified on the compat images: TimescaleDB 385→46 rows, Cloudberry 479→67, AlloyDB Omni 542→70 (69 as a least-privilege role). --- docs/AGENT.md | 86 ++++---- docs/BACKLOG.md | 86 +++----- src/lib/agent/composed-sql.ts | 50 +++-- src/lib/agent/context-snapshot.ts | 45 ++++- src/lib/agent/types.ts | 5 +- src/lib/db/compatibility.ts | 6 +- tests/evals/injection.test.ts | 14 +- tests/evals/legacy-surface-coverage.test.ts | 18 +- tests/evals/plan-grounding.test.ts | 3 +- .../isolated/agent-investigation-e2e.test.ts | 61 ++---- tests/isolated/agent-investigation.test.ts | 64 +++--- tests/isolated/fixtures/agent-eval-harness.ts | 14 +- tests/unit/lib/agent/composed-sql.test.ts | 12 ++ tests/unit/lib/agent/context-snapshot.test.ts | 188 ++++++++++++++++-- tests/unit/lib/agent/types.test.ts | 3 +- 15 files changed, 399 insertions(+), 256 deletions(-) diff --git a/docs/AGENT.md b/docs/AGENT.md index a33a16f2..c9072383 100644 --- a/docs/AGENT.md +++ b/docs/AGENT.md @@ -84,21 +84,38 @@ Two companion pages carry what this one deliberately does not: ## Table of Contents -- [Turning it on](#turning-it-on) -- [What a run is](#what-a-run-is) -- [Durability and resume](#durability-and-resume) -- [The tool set](#the-tool-set) -- [What bounds a run](#what-bounds-a-run) -- [Supported models](#supported-models) -- [The model side](#the-model-side) -- [Whether the run answered](#whether-the-run-answered) -- [What the removed AI panels did that a run does not](#what-the-removed-ai-panels-did-that-a-run-does-not) -- [HTTP surface](#http-surface) -- [The surface in the app](#the-surface-in-the-app) -- [Deployment](#deployment) -- [Package boundary](#package-boundary) -- [Module map](#module-map) -- [Known limitations](#known-limitations) +- [Agent Runtime — LibreDB Studio](#agent-runtime--libredb-studio) + - [Table of Contents](#table-of-contents) + - [Turning it on](#turning-it-on) + - [What a run is](#what-a-run-is) + - [The conversation a run belongs to](#the-conversation-a-run-belongs-to) + - [What a plan run knows](#what-a-plan-run-knows) + - [What the inventory is an inventory OF](#what-the-inventory-is-an-inventory-of) + - [The statement a plan run drafts](#the-statement-a-plan-run-drafts) + - [Durability and resume](#durability-and-resume) + - [A drive that dies before the loop](#a-drive-that-dies-before-the-loop) + - [The tool set](#the-tool-set) + - [The query-optimization template](#the-query-optimization-template) + - [The database-assessment template](#the-database-assessment-template) + - [The operations template](#the-operations-template) + - [The data-analysis template](#the-data-analysis-template) + - [Presenting an answer](#presenting-an-answer) + - [Handing the answer to the editor (auto-execute)](#handing-the-answer-to-the-editor-auto-execute) + - [What the fence is proved to hold against](#what-the-fence-is-proved-to-hold-against) + - [What bounds a run](#what-bounds-a-run) + - [Supported models](#supported-models) + - [The model side](#the-model-side) + - [What a refused model looks like in the app](#what-a-refused-model-looks-like-in-the-app) + - [Whether the run answered](#whether-the-run-answered) + - [The eval harness](#the-eval-harness) + - [What the removed AI panels did that a run does not](#what-the-removed-ai-panels-did-that-a-run-does-not) + - [HTTP surface](#http-surface) + - [The surface in the app](#the-surface-in-the-app) + - [Deployment](#deployment) + - [Package boundary](#package-boundary) + - [Module map](#module-map) + - [Known limitations](#known-limitations) + - [Related documentation](#related-documentation) ## Turning it on @@ -2485,39 +2502,6 @@ the role's own grants are the whole boundary (A3). fix, left unmodelled because the user-index reader drops `COLLATE` too and honouring it in one reader only would make the inventory disagree with itself. -- **B52** — the composed PostgreSQL grounding capture is refused by what the IMAGE ships rather - than by a wide user schema, and it has now been measured on three different servers. - `composeCatalogRead` projects one row per COLUMN against `maxResultRows: 200` and refuses rather - than truncates, which its own comment estimates as "roughly 25 tables of eight columns". Measured on - 2026-08-20 against a stock TimescaleDB 2.29.2 (PostgreSQL 17.11) with TWO user tables: - `information_schema.columns` answers 478 rows, of which 473 belong to the extension's own schemas - and 5 to the user, so the capture is refused and the plan run answers ungrounded. Verified as - server-caused, not path-caused: plain PostgreSQL 18 and YugabyteDB both captured their schemas under - the same least-privilege role, and granting that role the internal schemas changed nothing. - Reproduced the same day on Apache Cloudberry 2.1.0-incubating (PostgreSQL 14.4), which is a fork - rather than an extension, so the shape is not one product's: with the same two user tables the read - is refused as `CATALOG_READ_REFUSED` at **289 rows against the 200 allowed**, **282** of those rows - belonging to Cloudberry's own `gp_toolkit` schema and 7 to the user's two tables. The total is - per-role and only means something with the role named: the same read as `gpadmin` answers 481 rows, - 470 of them `gp_toolkit`, 7 `public` and 4 `pg_ext_aux`. Cloudberry also puts a second wall in front - of the first, which a user hitting it needs to know about: `gpadmin` is the login the engine gives - you and it is a superuser, so the execution profile refuses the role as unverified or too broad - (`is_superuser`, `reads_server_files`, `writes_server_files`, `executes_programs`) and an agent run - there needs a hand-made least-privilege `agentUser` before it can even reach the row budget - and - then hits it. Measured a third time the same day on AlloyDB Omni 17.9.0 (PostgreSQL 17.9), which is - the instance that decides the fix: the read is refused at **536 rows against the 200 allowed** with - only **7** of them the user's, and the overflow is **not** in an internal schema - 341 of the 348 - rows attributed to `public` are the 49 extension views the image installs into `public` itself, with - `google_ml` 144 and `ai` 44 behind them. Narrowing the capture to `schema=public` therefore still - refuses, at 348 rows, so the candidate fix of excluding the schemas the object browser treats as - internal - which would have rescued both TimescaleDB and Cloudberry - is refuted here, and the only - surviving fix is to aggregate columns per table so the projection is one row per OBJECT. AlloyDB - Omni is a superuser-login image too, so it hits Cloudberry's first wall as well. Two controls keep - those numbers attributable: plain PostgreSQL 18.4 in the same pass projects 7 rows and captures its - 2 tables, and the 0-rows-as-agent-role result on the `relations` kind reproduces identically on that - baseline, so it is PostgreSQL's privilege rule and not an AlloyDB property. The agent is therefore - unusable out of the box on all three, and the same shape will appear on any PostgreSQL whose image - ships wide catalogs or wide extension views before the user has created anything. - **B59** — per-model WORDING has nowhere to go. A sentence is a measured value here (twice a shared change won cells and lost others, and had to be reverted whole), and the per-model override is gone: the document refuses wording and nothing else can populate it. Refusing unsigned prompt text @@ -2527,6 +2511,12 @@ the role's own grants are the whole boundary (A3). and never what it said, so an empty completion reaches it too and a model's recorded `retryEmptyTurn: false` decides nothing. Pinned as it behaves rather than narrowed, because the narrowing would move behaviour five passing runs were measured under. +- **B76** — the aggregated capture that fixed the wide-catalog refusal (B52) now ADMITS the image's own + extension views, so a grounded run on TimescaleDB, Cloudberry or AlloyDB Omni reasons over an + inventory that is mostly internal objects (measured: AlloyDB's least-privilege role sees 67 extension + views beside its 2 user tables). Not a privilege leak — the role genuinely sees them — but grounding + noise; the object set was the same under the flat projection, which refused before any of it reached + a run. **Settled as limits rather than as work.** The seven below have no entry in `docs/BACKLOG.md`, and that is the point: each is how the product behaves, stated where a reader of this document will meet diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 0680bca5..78ad4065 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -40,7 +40,7 @@ None of it is a GitHub issue. - [Security Phase 2 deferrals](#security-phase-2-deferrals) — C3–C10 · 8 - [Security Phase 3 deferrals](#security-phase-3-deferrals) — K4 - [Agent M1 deferrals (#328)](#agent-m1-deferrals-328) — A1–A5 · 4 -- [Agent M2 deferrals (#329)](#agent-m2-deferrals-329) — B2–B75 · 22 +- [Agent M2 deferrals (#329)](#agent-m2-deferrals-329) — B2–B76 · 22 --- @@ -1809,66 +1809,6 @@ a run resumed often enough passes any constant. per-drive constant — most likely as part of B6 — with a test that drives one run twice past the cap and shows the first drive's cited results still readable, or the surface stating that they are not. -### B52. The grounding capture's row cap is reached by what the image ships, not by a wide user schema - -`composeCatalogRead` records a known limitation with a number: the PostgreSQL projection is one row per -COLUMN against `maxResultRows: 200`, so an unnarrowed call "overflows at roughly 25 tables of eight -columns". That estimate frames the cap as something a large user schema reaches. Three measurements say -otherwise. - -**TimescaleDB — two user tables are enough.** Measured 2026-08-20 against -`timescale/timescaledb:latest-pg17` (2.29.2 on PostgreSQL 17.11). `information_schema.columns` outside -`pg_catalog` and `information_schema` answers **478 rows**, of which **473 belong to the extension** and -**5 are the user's**. The read is refused rather than truncated, by design, so the plan run answers -ungrounded with "This run was given no inventory of this database." The identical run against plain -PostgreSQL 18 captured "3 tables, fingerprint ctx_0d63" and named them. Granting the agent role USAGE -and SELECT on the internal schemas does not change the outcome, so this is the row cap and not a -privilege. - -**Cloudberry — and it is not an extension.** Measured against `woblerr/cloudberry:2.1.0-incubating` -(PostgreSQL 14.4) with the same two user tables: `CATALOG_READ_REFUSED`, "289 rows > 200 allowed". **282 -of those 289 belong to `gp_toolkit`.** The figure is per-role: the same read as `gpadmin` answers **481** -rows. Cloudberry is a PostgreSQL fork rather than a PostgreSQL carrying an extension, so what -generalises is narrower than the first measurement suggested — any PostgreSQL-wire server whose own -catalogs are wide before the user creates anything. - -Cloudberry also fails one step earlier, which matters for anyone trying to work around this. Its usual -login is `gpadmin`, a superuser, and the agent's execution profile refuses that role as too broad. So -the row budget is only reached after a least-privilege `agentUser` has been created by hand — and it is -then reached anyway. - -**AlloyDB Omni settles which fix is viable.** Measured against `google/alloydbomni:17.9.0` (PostgreSQL -17.9), same two user tables: `CATALOG_READ_REFUSED`, "536 rows > 200 allowed". Only **7 of the 536 are -the user's**. As the agent role sees it: `public` **348**, `google_ml` 144, `ai` 44 — and **341 of the -348 in `public` are the 49 extension views the image installs into `public` itself**. - -That is what makes it decisive. On TimescaleDB and Cloudberry the overflow sits in a separate internal -schema, so the candidate fix "exclude the schemas the object browser already treats as internal" would -rescue both. Here it rescues nothing: narrowing to `schema=public` still refuses, at **348 rows against -200**. The only selector that fits is a single table (`schema=public table=orders` projects 4 rows), -which is not a schema capture at all. - -**So of the two candidate fixes only one survives: aggregate columns per table so the projection is one -row per OBJECT, symmetric with the SQLite side. The schema-exclusion fix is refuted and should not be -attempted.** - -Two controls keep the AlloyDB numbers attributable. Plain PostgreSQL 18.4, in the same pass, projects -**7 rows** and captures its 2 tables. And the `relations` capture kind projects 0 rows as the agent role -and 3 as a superuser on AlloyDB — but the plain PostgreSQL baseline behaves identically, so that is -PostgreSQL's own privilege rule (B44) and not an AlloyDB property. - -AlloyDB Omni also fails the earlier step, for Cloudberry's reason: as the image's own `postgres` -superuser both profiles are refused with `PROFILE_PRIVILEGES_TOO_BROAD`. With a hand-made -least-privilege role both acquire and `queryReadOnly` is present, so the boundary works — and the capture -is refused anyway. - -The consequence: the agent is unusable out of the box on TimescaleDB, Cloudberry and AlloyDB Omni, and -the same shape will appear on any PostgreSQL-wire server whose image ships wide catalogs or wide -extension views before the user creates anything. - -**Done when:** a plan run against a stock TimescaleDB, one against a stock Cloudberry and one against a -stock AlloyDB Omni all report a captured schema naming the user's tables. - ### B59. Per-model instructions have nowhere to go, and the mechanism that held them is gone Wording is measured, not constant: this repository twice changed a shared sentence, won several @@ -2025,3 +1965,27 @@ so), and none of them has been measured. **Done when:** a resume onto a repointed connection does one stated thing, and the run's own record says which. + +### B76. The aggregated grounding capture admits the image's own extension views + +B52's aggregation made a stock image's wide catalog capture SUCCEED instead of refusing, and the price +is that it succeeds by admitting the image's own objects. Measured live on 2026-09-01 against the +`compat` profile images (`database-compose.yml`), with two user tables seeded: + +- TimescaleDB (`timescale/timescaledb:latest-pg17`): 385 column rows aggregate to 46 tables. +- Cloudberry (`woblerr/cloudberry:2.1.0-incubating`): 479 to 67 as gpadmin, 53 as a least-privilege role. +- AlloyDB Omni (`google/alloydbomni:17.9.0`): 542 to 70 as postgres, 69 as a least-privilege role. + +AlloyDB is the sharp case: the least-privilege role's 69 tables are 2 user tables plus 67 `ai.*`, +`google_db_advisor_*` and `hypopg_list_indexes` extension views. Not a privilege leak — +`information_schema.columns` applies its own visibility rules and the role genuinely sees those views — +but grounding noise: the inventory a run reasons over is mostly objects the user did not create, and the +model-facing pack spends `AGENT_CONTEXT_PACK_MAX_CHARS` ranking them against the objective. The old flat +projection had the same object set; it just refused before any of it reached a run. + +The candidate fixes are the ones B52 recorded and set aside: filter by `table_type`, or exclude the +objects the object browser already treats as internal. B52 refuted only the schema-exclusion variant AS +A FIX FOR THE ROW CAP; as a fix for grounding noise the question is open and unmeasured. + +**Done when:** a run grounded on one of these images reasons over the user's objects — or the internal +ones are excluded — with a test per shape (TimescaleDB, Cloudberry, AlloyDB Omni). diff --git a/src/lib/agent/composed-sql.ts b/src/lib/agent/composed-sql.ts index d771b4f6..f485503d 100644 --- a/src/lib/agent/composed-sql.ts +++ b/src/lib/agent/composed-sql.ts @@ -81,9 +81,10 @@ export const MAX_CATALOG_SELECTOR_LENGTH = 128; * how many reads the inventory takes and a single composed monster would have to be * verified per dialect anyway. Each kind is one bounded read (`sql.query.read`) * under the same descriptor, so the split costs statements out of the run's budget - * and buys nothing in privilege — which is exactly the trade the row cap forces: - * one flat projection per kind stays diagnosable when it overflows, where a nested - * aggregate would come back as one unreadable row-per-table blob. + * and buys nothing in privilege — which is exactly the trade the row cap forces. + * The PostgreSQL column kind aggregates its columns per table so a stock image's + * own catalog cannot overflow the cap (B52); the relation and index kinds stay + * flat, where an overflow is a narrowing the selector names. * * `statistics` is the newest and the only one whose values are ESTIMATES: it reads * what the engine already recorded about table sizes and column distributions, and @@ -153,14 +154,42 @@ function equalsClause(column: string, value: string | undefined, field: string, return ` AND ${column} = ${quoteLiteral(assertSelector(value, field), dialect)}`; } +/** + * The column inventory, one row per TABLE with its columns aggregated (B52). + * + * The projection used to be one row per COLUMN. Against `maxResultRows: 200` that + * refused an unnarrowed capture on any PostgreSQL image whose own catalogs are wide + * before the user creates anything — measured on TimescaleDB (478 rows), Cloudberry + * (289) and AlloyDB Omni (536), where the user's own tables were a handful of those + * rows. Aggregating the columns per table makes the projection one row per OBJECT, + * symmetric with the SQLite side, so a stock image answers a few dozen rows instead + * of a few hundred. + * + * `json_agg … ORDER BY ordinal_position` keeps the column order, which is the one + * property the flat projection used to guarantee. The object keys are the ones + * `buildPostgresTables` reads (`name`, `type`, `nullable`), so the fold parses one + * array per table instead of one row per column. + * + * The row budget now counts TABLES rather than columns, which is what a wide + * catalog needs; two bounds still stand and are worth naming. A schema with more + * than `maxResultRows` tables is still refused — B52 removes the per-column + * overflow, not the per-table one — and a single table whose aggregated JSON + * exceeds `maxResultBytes` is refused by the byte budget, the same backstop every + * read meets, measured over the serialized rows. The model-facing pack then + * bounds the prompt independently (`MAX_COLUMNS_PER_TABLE`, + * `AGENT_CONTEXT_PACK_MAX_CHARS`), so a wide table does not spend the context + * window on itself. + */ function composePostgresCatalog(selector: AgentCatalogSelector): string { return ( - "SELECT table_schema, table_name, column_name, data_type, is_nullable, ordinal_position " + + "SELECT table_schema, table_name, json_agg(json_build_object(" + + "'name', column_name, 'type', data_type, 'nullable', is_nullable) " + + "ORDER BY ordinal_position) AS columns " + "FROM information_schema.columns " + "WHERE table_schema NOT IN ('pg_catalog', 'information_schema')" + equalsClause("table_schema", selector.schema, "schema", "postgres") + equalsClause("table_name", selector.table, "table", "postgres") + - " ORDER BY table_schema, table_name, ordinal_position" + " GROUP BY table_schema, table_name ORDER BY table_schema, table_name" ); } @@ -638,14 +667,9 @@ const ESTIMATING_EXPLAIN_PREFIX: Partial> = { * claims to be whole is worse than a refusal a caller can narrow — and the selector * is how a caller narrows it. * - * KNOWN LIMITATION, with its number: the PostgreSQL projection is one row per COLUMN, - * so against `maxResultRows: 200` an unnarrowed call overflows at roughly 25 tables of - * eight columns and comes back as a repairable database error. The engine's message - * names the budget, so it is diagnosable, but it does cost one repair attempt. The - * SQLite side is one row per OBJECT and is nowhere near the cap. Making the two - * symmetric means aggregating columns per table, which changes what a caller parses - * out of the result — that decision belongs with the context snapshot that consumes - * it, not here. + * The PostgreSQL column kind aggregates its columns per table (B52), one row per + * OBJECT and symmetric with the SQLite side, so a stock image's own wide catalog + * no longer overflows the row cap before the user has created anything. */ export function composeCatalogRead(dialect: DatabaseType, selector: AgentCatalogSelector): string { if (!Object.hasOwn(CATALOG_COMPOSERS, dialect)) { diff --git a/src/lib/agent/context-snapshot.ts b/src/lib/agent/context-snapshot.ts index e0062033..dfd23c4d 100644 --- a/src/lib/agent/context-snapshot.ts +++ b/src/lib/agent/context-snapshot.ts @@ -65,10 +65,11 @@ * identity it advertises is the one its own inventory produces. * * SQLite and PostgreSQL answer differently and the asymmetry is structural, not - * cosmetic: PostgreSQL has three flat catalog projections, while SQLite has no - * structured catalog on this path at all (the guard refuses every `pragma_*` - * function) and its columns, keys and relations are read out of the DDL text the - * engine stored — see `sqlite-ddl.ts`. + * cosmetic: PostgreSQL aggregates its columns into one projection per table and + * keeps its relation and index projections flat, while SQLite has no structured + * catalog on this path at all (the guard refuses every `pragma_*` function) and + * its columns, keys and relations are read out of the DDL text the engine stored — + * see `sqlite-ddl.ts`. */ import { createHash } from "node:crypto"; @@ -258,6 +259,28 @@ function truthy(value: unknown): boolean { const qualified = (schema: unknown, table: unknown): string => `${text(schema)}.${text(table)}`; +/** + * The column list a PostgreSQL column row carries, aggregated by `json_agg` (B52). + * + * `pg` parses a `json` column into an array in memory, but the same rows may arrive + * as a JSON string from another transport or from a fixture, so both are read. + * Anything else is the empty inventory rather than an exception: a row this cannot + * read says nothing about its columns, and refusing it here would turn one malformed + * row into a lost snapshot. + */ +function parsePostgresColumns(value: unknown): readonly Record[] { + if (Array.isArray(value)) return value as readonly Record[]; + if (typeof value === "string" && value.trim() !== "") { + try { + const parsed: unknown = JSON.parse(value); + if (Array.isArray(parsed)) return parsed as readonly Record[]; + } catch { + return []; + } + } + return []; +} + /** Applies `change` to a known table, and drops the row when there is no such table. */ function attach(tables: TableIndex, name: string, change: (table: MutableTable) => void): void { const table = tables.get(name); @@ -281,12 +304,14 @@ function buildPostgresTables(rows: ReadonlyMap ({ rows: [ - { table_schema: "public", table_name: tableName, column_name: "id", data_type: "integer", is_nullable: "NO" }, - { table_schema: "public", table_name: tableName, column_name: columnName, data_type: "text", is_nullable: "YES" }, + { + table_schema: "public", + table_name: tableName, + columns: [ + { name: "id", type: "integer", nullable: "NO" }, + { name: columnName, type: "text", nullable: "YES" }, + ], + }, ], - fields: ["table_schema", "table_name", "column_name", "data_type", "is_nullable"], - rowCount: 2, + fields: ["table_schema", "table_name", "columns"], + rowCount: 1, executionTime: 3, }); diff --git a/tests/evals/legacy-surface-coverage.test.ts b/tests/evals/legacy-surface-coverage.test.ts index c795ae47..eae10872 100644 --- a/tests/evals/legacy-surface-coverage.test.ts +++ b/tests/evals/legacy-surface-coverage.test.ts @@ -182,16 +182,20 @@ const ASSESSMENT = "Tell me what is wrong with this database."; /** One under-populated, unindexed foreign key — the shape Autopilot's index section was for. */ const PG_COLUMNS = [ - { table_schema: "public", table_name: "employees", column_name: "emp_no", data_type: "integer", is_nullable: "NO" }, { table_schema: "public", table_name: "employees", - column_name: "department_id", - data_type: "integer", - is_nullable: "YES", + columns: [ + { name: "emp_no", type: "integer", nullable: "NO" }, + { name: "department_id", type: "integer", nullable: "YES" }, + { name: "hired_on", type: "date", nullable: "YES" }, + ], + }, + { + table_schema: "public", + table_name: "departments", + columns: [{ name: "id", type: "integer", nullable: "NO" }], }, - { table_schema: "public", table_name: "employees", column_name: "hired_on", data_type: "date", is_nullable: "YES" }, - { table_schema: "public", table_name: "departments", column_name: "id", data_type: "integer", is_nullable: "NO" }, ]; const PG_RELATIONS = [ @@ -237,7 +241,7 @@ const SQLITE_OBJECTS = [ const INVENTORY: Readonly ReturnType | null>> = { postgres: (sql) => { if (sql.includes("information_schema.columns")) { - return rows(PG_COLUMNS, ["table_schema", "table_name", "column_name", "data_type", "is_nullable"]); + return rows(PG_COLUMNS, ["table_schema", "table_name", "columns"]); } if (sql.includes("pg_constraint")) { return rows(PG_RELATIONS, [ diff --git a/tests/evals/plan-grounding.test.ts b/tests/evals/plan-grounding.test.ts index 3531876c..8448cea2 100644 --- a/tests/evals/plan-grounding.test.ts +++ b/tests/evals/plan-grounding.test.ts @@ -206,7 +206,8 @@ const eventOfKind = ( describe("a refused capture records why it was refused", () => { test("postgres: the row budget refusal carries both numbers and no fabricated count", async () => { - // The measured shape of B52, as the provider itself raises it: `queryReadOnly` + // The shape the provider raises when a read overruns its row budget (B52's + // measurement): `queryReadOnly` // refuses rather than truncating, and the two numbers are the whole diagnosis — // 536 rows against a 200-row budget says "narrow the capture", where the reason // code alone says only "somebody said no". diff --git a/tests/isolated/agent-investigation-e2e.test.ts b/tests/isolated/agent-investigation-e2e.test.ts index 1cc40ed6..4c386216 100644 --- a/tests/isolated/agent-investigation-e2e.test.ts +++ b/tests/isolated/agent-investigation-e2e.test.ts @@ -105,56 +105,26 @@ function engineRows(data: Record[]): EngineAnswer { /** * The column inventory `information_schema.columns` answers for this fixture's two - * tables — one row per column, which is the projection `composed-sql.ts` composes. + * tables — one row per table, which is the projection `composed-sql.ts` composes. */ const PG_COLUMN_ROWS: Record[] = [ { table_schema: "public", table_name: "customers", - column_name: "id", - data_type: "integer", - is_nullable: "NO", - ordinal_position: 1, - }, - { - table_schema: "public", - table_name: "customers", - column_name: "name", - data_type: "text", - is_nullable: "NO", - ordinal_position: 2, + columns: [ + { name: "id", type: "integer", nullable: "NO" }, + { name: "name", type: "text", nullable: "NO" }, + ], }, { table_schema: "public", table_name: "orders", - column_name: "id", - data_type: "integer", - is_nullable: "NO", - ordinal_position: 1, - }, - { - table_schema: "public", - table_name: "orders", - column_name: "customer_id", - data_type: "integer", - is_nullable: "NO", - ordinal_position: 2, - }, - { - table_schema: "public", - table_name: "orders", - column_name: "status", - data_type: "text", - is_nullable: "NO", - ordinal_position: 3, - }, - { - table_schema: "public", - table_name: "orders", - column_name: "total_cents", - data_type: "integer", - is_nullable: "NO", - ordinal_position: 4, + columns: [ + { name: "id", type: "integer", nullable: "NO" }, + { name: "customer_id", type: "integer", nullable: "NO" }, + { name: "status", type: "text", nullable: "NO" }, + { name: "total_cents", type: "integer", nullable: "NO" }, + ], }, ]; @@ -742,11 +712,12 @@ describe("the whole investigation, against the PostgreSQL suite's engine fixture columnNames: ["status", "order_count"], rowCount: 2, rowValue: "paid", - // The PostgreSQL catalog read is a structured column inventory, so a column of - // the table the selector narrowed to is what the model sees. + // The PostgreSQL catalog read is a structured column inventory, aggregated per + // table (B52), so a column of the table the selector narrowed to is what the + // model sees. catalogText: "customer_id", - // One row per COLUMN of `orders`, and none of `customers`. - catalogRowCount: 4, + // One row per TABLE: the selector narrowed to `orders`, so exactly one. + catalogRowCount: 1, rows: PG_STATUS_ROWS, }); }); diff --git a/tests/isolated/agent-investigation.test.ts b/tests/isolated/agent-investigation.test.ts index ba86b3ab..62d6bcaf 100644 --- a/tests/isolated/agent-investigation.test.ts +++ b/tests/isolated/agent-investigation.test.ts @@ -827,19 +827,15 @@ describe("planning mode runs no statement of the user's", () => { { table_schema: "public", table_name: "orders", - column_name: "customer_id", - data_type: "character varying", - is_nullable: "NO", + columns: [{ name: "customer_id", type: "character varying", nullable: "NO" }], }, { table_schema: "public", table_name: "customers", - column_name: "id", - data_type: "character varying", - is_nullable: "NO", + columns: [{ name: "id", type: "character varying", nullable: "NO" }], }, ], - fields: ["table_schema", "table_name", "column_name", "data_type", "is_nullable"], + fields: ["table_schema", "table_name", "columns"], rowCount: 2, }); } @@ -930,11 +926,9 @@ describe("planning mode runs no statement of the user's", () => { rows: Array.from({ length: 300 }, (_unused, index) => ({ table_schema: "public", table_name: `department_table_${index}`, - column_name: "identifier_column", - data_type: "character varying", - is_nullable: "NO", + columns: [{ name: "identifier_column", type: "character varying", nullable: "NO" }], })), - fields: ["table_schema", "table_name", "column_name", "data_type", "is_nullable"], + fields: ["table_schema", "table_name", "columns"], rowCount: 300, }) : queryResult({ rows: [], fields: [], rowCount: 0 }); @@ -1114,27 +1108,19 @@ describe("planning mode runs no statement of the user's", () => { { table_schema: "public", table_name: "orders", - column_name: "id", - data_type: "integer", - is_nullable: "NO", - }, - { - table_schema: "public", - table_name: "orders", - column_name: "customer_id", - data_type: "integer", - is_nullable: "NO", + columns: [ + { name: "id", type: "integer", nullable: "NO" }, + { name: "customer_id", type: "integer", nullable: "NO" }, + ], }, { table_schema: "public", table_name: "customers", - column_name: "id", - data_type: "integer", - is_nullable: "NO", + columns: [{ name: "id", type: "integer", nullable: "NO" }], }, ], - fields: ["table_schema", "table_name", "column_name", "data_type", "is_nullable"], - rowCount: 3, + fields: ["table_schema", "table_name", "columns"], + rowCount: 2, }); } if (sql.includes("pg_constraint")) { @@ -1767,12 +1753,10 @@ describe("planning mode runs no statement of the user's", () => { { table_schema: "public", table_name: "invoices", - column_name: "id", - data_type: "integer", - is_nullable: "NO", + columns: [{ name: "id", type: "integer", nullable: "NO" }], }, ], - fields: ["table_schema", "table_name", "column_name", "data_type", "is_nullable"], + fields: ["table_schema", "table_name", "columns"], rowCount: 1, }) : queryResult({ rows: [], fields: [], rowCount: 0 }); @@ -1838,11 +1822,9 @@ describe("planning mode runs no statement of the user's", () => { rows: Array.from({ length: 300 }, (_, index) => ({ table_schema: "public", table_name: `department_table_${index}`, - column_name: "identifier_column", - data_type: "character varying", - is_nullable: "NO", + columns: [{ name: "identifier_column", type: "character varying", nullable: "NO" }], })), - fields: ["table_schema", "table_name", "column_name", "data_type", "is_nullable"], + fields: ["table_schema", "table_name", "columns"], rowCount: 300, }); } @@ -2387,10 +2369,18 @@ describe("planning mode runs no statement of the user's", () => { sql.includes("information_schema.columns") ? queryResult({ rows: [ - { table_schema: "public", table_name: "film", column_name: "title", data_type: "text" }, - { table_schema: "public", table_name: "actor", column_name: "name", data_type: "text" }, + { + table_schema: "public", + table_name: "film", + columns: [{ name: "title", type: "text", nullable: "NO" }], + }, + { + table_schema: "public", + table_name: "actor", + columns: [{ name: "name", type: "text", nullable: "NO" }], + }, ], - fields: ["table_schema", "table_name", "column_name", "data_type"], + fields: ["table_schema", "table_name", "columns"], rowCount: 2, }) : queryResult({ rows: [], fields: [], rowCount: 0 }); diff --git a/tests/isolated/fixtures/agent-eval-harness.ts b/tests/isolated/fixtures/agent-eval-harness.ts index 73739134..7151171a 100644 --- a/tests/isolated/fixtures/agent-eval-harness.ts +++ b/tests/isolated/fixtures/agent-eval-harness.ts @@ -151,10 +151,14 @@ export const DEPARTMENTS = [ "research", ] as const; -const PG_COLUMN_ROWS = DEPARTMENTS.flatMap((table) => [ - { table_schema: "public", table_name: table, column_name: "id", data_type: "integer", is_nullable: "NO" }, - { table_schema: "public", table_name: table, column_name: "name", data_type: "text", is_nullable: "YES" }, -]); +const PG_COLUMN_ROWS = DEPARTMENTS.map((table) => ({ + table_schema: "public", + table_name: table, + columns: [ + { name: "id", type: "integer", nullable: "NO" }, + { name: "name", type: "text", nullable: "YES" }, + ], +})); const SQLITE_DDL_ROWS = DEPARTMENTS.map((table) => ({ name: table, @@ -220,7 +224,7 @@ export const EVAL_ENGINES: Readonly> = Obje groundingReads: ["information_schema.columns", "pg_constraint", "pg_index", "pg_stats"], catalogAnswer: (sql) => { if (sql.includes("information_schema.columns")) { - return result(PG_COLUMN_ROWS, ["table_schema", "table_name", "column_name", "data_type", "is_nullable"]); + return result(PG_COLUMN_ROWS, ["table_schema", "table_name", "columns"]); } if (sql.includes("pg_constraint")) return result([], ["table_name"]); // Before `pg_index`: the statistics read joins `pg_class` to `pg_stats` and diff --git a/tests/unit/lib/agent/composed-sql.test.ts b/tests/unit/lib/agent/composed-sql.test.ts index cf3d1732..36f1f1f0 100644 --- a/tests/unit/lib/agent/composed-sql.test.ts +++ b/tests/unit/lib/agent/composed-sql.test.ts @@ -70,6 +70,18 @@ describe("composeCatalogRead — PostgreSQL", () => { test("orders the rows, so two identical inventories serialise identically", () => { expect(composeCatalogRead("postgres", {})).toContain("ORDER BY"); }); + + test("aggregates the columns per table, one row per object (B52)", () => { + const sql = composeCatalogRead("postgres", {}); + + expect(sql).toContain("json_agg"); + expect(sql).toContain("json_build_object"); + expect(sql).toContain("GROUP BY table_schema, table_name"); + expect(sql).toContain("ORDER BY ordinal_position"); + // The projection is per table: the column names sit only inside the aggregated + // object, never as top-level select items. + expect(sql).not.toContain("column_name, data_type, is_nullable"); + }); }); describe("composeCatalogRead — SQLite", () => { diff --git a/tests/unit/lib/agent/context-snapshot.test.ts b/tests/unit/lib/agent/context-snapshot.test.ts index b276ecd6..f9ad1028 100644 --- a/tests/unit/lib/agent/context-snapshot.test.ts +++ b/tests/unit/lib/agent/context-snapshot.test.ts @@ -18,6 +18,7 @@ import type { AgentToolContext } from "@/lib/agent/tools"; import type { AgentContextSnapshot, AgentRunEvent } from "@/lib/agent/types"; import { UNTRUSTED_CONTENT_BEGIN, UNTRUSTED_CONTENT_END } from "@/lib/agent/untrusted-content"; import { ConnectionError, ExecutionProfileError, QueryError } from "@/lib/db/errors"; +import { measureResultBytes } from "@/lib/db/providers/sql/read-only-budget"; import { ExecutionArtifactStore } from "@/lib/db/operations/artifacts"; import { ExecutionBudgetTracker } from "@/lib/db/operations/budgets"; import { @@ -74,19 +75,25 @@ function result(rows: readonly Record[]): QueryResult { }; } -/** What a PostgreSQL server answers each of the three composed catalog reads. */ +/** What a PostgreSQL server answers the composed column read: one row per table (B52). */ const PG_COLUMNS = [ - { table_schema: "public", table_name: "orders", column_name: "id", data_type: "integer", is_nullable: "NO" }, { table_schema: "public", table_name: "orders", - column_name: "customer_id", - data_type: "integer", - is_nullable: "NO", + columns: [ + { name: "id", type: "integer", nullable: "NO" }, + { name: "customer_id", type: "integer", nullable: "NO" }, + { name: "total", type: "numeric", nullable: "YES" }, + ], + }, + { + table_schema: "public", + table_name: "customers", + columns: [ + { name: "id", type: "integer", nullable: "NO" }, + { name: "name", type: "text", nullable: "YES" }, + ], }, - { table_schema: "public", table_name: "orders", column_name: "total", data_type: "numeric", is_nullable: "YES" }, - { table_schema: "public", table_name: "customers", column_name: "id", data_type: "integer", is_nullable: "NO" }, - { table_schema: "public", table_name: "customers", column_name: "name", data_type: "text", is_nullable: "YES" }, ]; const PG_RELATIONS = [ @@ -248,6 +255,141 @@ describe("captureContextSnapshot — PostgreSQL", () => { }); }); +/** + * B52 measured the failure on three PostgreSQL-wire servers whose own catalogs are + * wide before the user creates anything: TimescaleDB, Cloudberry and AlloyDB Omni. + * Each answered hundreds of COLUMN rows against `maxResultRows: 200` under the old + * flat projection, so the capture was refused. The aggregated projection answers + * one row per TABLE, which is what these fixtures model: the column count of each + * shape is deliberately above 200 while the table count stays far below it, and the + * capture must still succeed and name the user's tables. + */ +describe("captureContextSnapshot — wide PostgreSQL catalogs (B52)", () => { + const USER_TABLES = [ + { + table_schema: "public", + table_name: "orders", + columns: [{ name: "id", type: "integer", nullable: "NO" }], + }, + { + table_schema: "public", + table_name: "customers", + columns: [{ name: "id", type: "integer", nullable: "NO" }], + }, + ]; + + const extensionRows = (schema: string, tables: number, columnsPerTable: number) => + Array.from({ length: tables }, (_unused, tableIndex) => ({ + table_schema: schema, + table_name: `ext_table_${tableIndex}`, + columns: Array.from({ length: columnsPerTable }, (_unused, columnIndex) => ({ + name: `col_${columnIndex}`, + type: "integer", + nullable: "NO", + })), + })); + + const cases = [ + // TimescaleDB: 30 extension tables × 16 columns = 480 column rows in the flat shape. + { name: "TimescaleDB", schema: "_timescaledb_catalog", tables: 30, columnsPerTable: 16 }, + // Cloudberry: 80 `gp_toolkit` views × 4 columns = 320 column rows. + { name: "Cloudberry", schema: "gp_toolkit", tables: 80, columnsPerTable: 4 }, + // AlloyDB Omni: 60 extension views in `public` × 9 columns = 540 column rows. + { name: "AlloyDB Omni", schema: "public", tables: 60, columnsPerTable: 9 }, + ]; + + for (const server of cases) { + test(`a ${server.name}-shaped catalog captures, and names the user's tables`, async () => { + const h = harness("postgres", async (sql: string) => { + if (sql.includes("information_schema.columns")) { + return result([...extensionRows(server.schema, server.tables, server.columnsPerTable), ...USER_TABLES]); + } + return result([]); + }); + + const capture = await captureContextSnapshot(h.context); + + expect(capture.kind).toBe("captured"); + if (capture.kind !== "captured") throw new Error("unreachable"); + expect(capture.snapshot.tables.map((table) => table.name)).toEqual( + expect.arrayContaining(["public.orders", "public.customers"]), + ); + }); + } + + test("reads columns that arrive as a JSON string, from another transport", async () => { + const h = harness("postgres", async (sql: string) => + sql.includes("information_schema.columns") + ? result([ + { + table_schema: "public", + table_name: "orders", + columns: JSON.stringify([{ name: "id", type: "integer", nullable: "NO" }]), + }, + ]) + : result([]), + ); + + const capture = await captureContextSnapshot(h.context); + + expect(capture.kind).toBe("captured"); + if (capture.kind !== "captured") throw new Error("unreachable"); + expect(capture.snapshot.tables.find((table) => table.name === "public.orders")?.columns).toEqual([ + { name: "id", type: "integer", nullable: false, isPrimary: false }, + ]); + }); + + test("a malformed columns value yields an empty column list, never a lost snapshot", async () => { + for (const malformed of [null, "", "not json", 42, { name: "id" }]) { + const h = harness("postgres", async (sql: string) => + sql.includes("information_schema.columns") + ? result([{ table_schema: "public", table_name: "orders", columns: malformed }]) + : result([]), + ); + + const capture = await captureContextSnapshot(h.context); + + expect(capture.kind).toBe("captured"); + if (capture.kind !== "captured") throw new Error("unreachable"); + const columns = capture.snapshot.tables.find((table) => table.name === "public.orders")?.columns; + expect(columns, `columns = ${JSON.stringify(malformed)}`).toEqual([]); + } + }); + + test("preserves column order for a wide table, and the aggregated payload stays inside the byte budget", async () => { + const width = 2_000; + const rows = [ + { + table_schema: "public", + table_name: "wide", + columns: Array.from({ length: width }, (_unused, index) => ({ + name: `col_${index}`, + type: "integer", + nullable: "NO", + })), + }, + ]; + + // The byte budget is the backstop the row budget leaves behind: even a + // 2,000-column table, aggregated into one JSON array, stays under the + // 262_144-byte cap the read-only profile enforces. + expect(measureResultBytes(rows)).toBeLessThan(262_144); + + const h = harness("postgres", async (sql: string) => + sql.includes("information_schema.columns") ? result(rows) : result([]), + ); + + const capture = await captureContextSnapshot(h.context); + + expect(capture.kind).toBe("captured"); + if (capture.kind !== "captured") throw new Error("unreachable"); + const columns = capture.snapshot.tables.find((table) => table.name === "public.wide")?.columns; + expect(columns).toHaveLength(width); + expect(columns?.[0]?.name).toBe("col_0"); + expect(columns?.[width - 1]?.name).toBe(`col_${width - 1}`); + }); +}); + describe("captureContextSnapshot — SQLite", () => { test("takes two reads, because the table DDL carries the relations as well", async () => { const h = harness("sqlite"); @@ -317,16 +459,13 @@ describe("captureContextSnapshot — the fingerprint", () => { const withColumn = harness("postgres", async (sql: string) => sql.includes("information_schema.columns") - ? result([ - ...PG_COLUMNS, - { - table_schema: "public", - table_name: "orders", - column_name: "note", - data_type: "text", - is_nullable: "YES", - }, - ]) + ? result( + PG_COLUMNS.map((row) => + row.table_name === "orders" + ? { ...row, columns: [...row.columns, { name: "note", type: "text", nullable: "YES" }] } + : row, + ), + ) : answerPostgres(sql), ); const withIndex = harness("postgres", async (sql: string) => @@ -367,7 +506,18 @@ describe("captureContextSnapshot — the fingerprint", () => { // A column that changed type, keeping its name and position. const withRetypedColumn = harness("postgres", async (sql: string) => sql.includes("information_schema.columns") - ? result(PG_COLUMNS.map((row) => (row.column_name === "total" ? { ...row, data_type: "bigint" } : row))) + ? result( + PG_COLUMNS.map((row) => + row.table_name === "orders" + ? { + ...row, + columns: row.columns.map((column) => + column.name === "total" ? { ...column, type: "bigint" } : column, + ), + } + : row, + ), + ) : answerPostgres(sql), ); diff --git a/tests/unit/lib/agent/types.test.ts b/tests/unit/lib/agent/types.test.ts index 0653cceb..5db00b38 100644 --- a/tests/unit/lib/agent/types.test.ts +++ b/tests/unit/lib/agent/types.test.ts @@ -150,7 +150,8 @@ const EVENTS: Record = { // The capture that was REFUSED (B54). Its own kind rather than the entry above // carrying an absence, and the fixture is the row-budget case because that is the // shape with fields to get wrong — and because it is the one an operator has to - // diagnose: B52's 536 rows against a 200-row bound. No `tableCount` and no + // diagnose: 536 rows against a 200-row bound (the B52 measurement, now fixed). No + // `tableCount` and no // `fingerprint` exist on it at all, which is the absence rule in the type system // rather than in a convention (#477). "context-unavailable": { From b5b932510afd0c299645baa987ad2e3d5396173b Mon Sep 17 00:00:00 2001 From: koraysrn Date: Tue, 1 Sep 2026 15:04:54 +0300 Subject: [PATCH 2/3] docs(agent): remove dangling B52 citations and refresh compat caveats B52's backlog entry was deleted when the fix landed; the new B76 entry and the compat table still cited it. Reword so no citation outlives its entry, and update the TimescaleDB/Cloudberry/AlloyDB compat rows to the post-fix behaviour. --- docs/AGENT.md | 2 +- docs/BACKLOG.md | 10 ++++++---- docs/providers/README.md | 8 ++++---- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/docs/AGENT.md b/docs/AGENT.md index c9072383..7ea25733 100644 --- a/docs/AGENT.md +++ b/docs/AGENT.md @@ -2511,7 +2511,7 @@ the role's own grants are the whole boundary (A3). and never what it said, so an empty completion reaches it too and a model's recorded `retryEmptyTurn: false` decides nothing. Pinned as it behaves rather than narrowed, because the narrowing would move behaviour five passing runs were measured under. -- **B76** — the aggregated capture that fixed the wide-catalog refusal (B52) now ADMITS the image's own +- **B76** — the aggregated capture that fixed the wide-catalog refusal now ADMITS the image's own extension views, so a grounded run on TimescaleDB, Cloudberry or AlloyDB Omni reasons over an inventory that is mostly internal objects (measured: AlloyDB's least-privilege role sees 67 extension views beside its 2 user tables). Not a privilege leak — the role genuinely sees them — but grounding diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 78ad4065..5651c582 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -1968,7 +1968,8 @@ says which. ### B76. The aggregated grounding capture admits the image's own extension views -B52's aggregation made a stock image's wide catalog capture SUCCEED instead of refusing, and the price +The aggregation that fixed the row-cap refusal made a stock image's wide catalog capture SUCCEED instead +of refusing, and the price is that it succeeds by admitting the image's own objects. Measured live on 2026-09-01 against the `compat` profile images (`database-compose.yml`), with two user tables seeded: @@ -1983,9 +1984,10 @@ but grounding noise: the inventory a run reasons over is mostly objects the user model-facing pack spends `AGENT_CONTEXT_PACK_MAX_CHARS` ranking them against the objective. The old flat projection had the same object set; it just refused before any of it reached a run. -The candidate fixes are the ones B52 recorded and set aside: filter by `table_type`, or exclude the -objects the object browser already treats as internal. B52 refuted only the schema-exclusion variant AS -A FIX FOR THE ROW CAP; as a fix for grounding noise the question is open and unmeasured. +The candidate fixes are the ones the row-cap fix recorded and set aside: filter by `table_type`, or +exclude the objects the object browser already treats as internal. That fix refuted only the +schema-exclusion variant AS A FIX FOR THE ROW CAP; as a fix for grounding noise the question is open and +unmeasured. **Done when:** a run grounded on one of these images reasons over the user's objects — or the internal ones are excluded — with a test per shape (TimescaleDB, Cloudberry, AlloyDB Omni). diff --git a/docs/providers/README.md b/docs/providers/README.md index 10ab431f..96860968 100644 --- a/docs/providers/README.md +++ b/docs/providers/README.md @@ -63,13 +63,13 @@ the support column records how much of the product actually worked: | MariaDB | `mysql` | Full | 12.3.2-MariaDB-ubu2404 | Behaves as MySQL throughout. The version shown is MariaDB's full build string, passed through as the server gave it. **`performance_schema` ships OFF** (`@@performance_schema` = 0), so the cache-hit, queries-per-second and buffer-pool figures are absent and the slow-query list is empty until the server is started with `performance_schema=ON`; the tables exist either way, so nothing fails, it simply measures nothing. The schema tree, sizes, row counts, sessions and `EXPLAIN FORMAT=JSON` come from `information_schema` and are unaffected. Only 12.3 was probed; the 10.x `information_schema` surface was not. | | TiDB | `mysql` | Full | 8.0.11-TiDB-v8.5.1 | All surfaces answer. A freshly loaded table reads **0 rows and 0 B until TiDB's background statistics catch up** — they correct themselves, with no `ANALYZE`. Max connections reads 0 (TiDB's default, meaning unlimited), the slow-query panel is always empty (TiDB's slow log lives in `information_schema.SLOW_QUERY`), and storage stats list a phantom `InnoDB` entry. The Explain panel does not work either — TiDB rejects `EXPLAIN FORMAT='json'` — though the query itself runs normally. Probed on a standalone `--store=unistore` server only; PD + TiKV was not probed. | | Vitess | `mysql` | Full | 8.0.43-Vitess (Vitess 24.0.2) | All fifteen surfaces answer and the browser is clean: 2 objects for 2 user tables. **Row counts and total size are exact**, checked against the engine (2000 rows read as 2000; 163840 bytes as 163840), and a foreign key is both read back and enforced. Two things do not work. **A running query cannot be cancelled**: vtgate refuses `KILL QUERY` with `VT07001` and the statement runs to completion (a 5 second `SLEEP` took its full 5003 ms). And per-index sizes always read 0 bytes, because Vitess names the InnoDB table after the physical shard database (`vt_probe_0`), which is also the name the table and index statistics show instead of the keyspace you connected to. Setting a session variable can fail where reading it works: `SET @@cte_max_recursion_depth` is rejected as an unknown system variable, while `SELECT @@cte_max_recursion_depth` answers 1000. Probed on an unsharded single-shard keyspace only; a sharded keyspace was not probed, and no permission error could be measured because `vttestserver` grants every login full rights. | -| ParadeDB | `postgres` | Full | ParadeDB 0.25.4 on PostgreSQL 18.6 | Probed 2026-08-27 against `paradedb/paradedb:0.25.4`. All fifteen surfaces answer and the numbers are correct (2000 rows read as 2000, 131072 bytes as 131072), with a foreign key and its indexes read back. **What it costs is width, not accuracy**: the object browser lists **41 objects for 2 user tables** and the index panel 74 rows, because ParadeDB ships nine extensions - `pg_search`, `vector`, `postgis` with its `tiger` geocoder, `pg_ivm`, `pg_stat_statements` and `paradedb`'s own tables. `spatial_ref_sys` (8500 rows) sits in `public` beside your tables. **Agent plan mode WORKS here, and that result refutes what the width predicted** - worth recording, because the expectation was [B52](../BACKLOG.md): the grounding capture reads what the ROLE can see, and a superuser sees 539 non-system columns where an unprivileged role sees **168**, the tiger geocoder's 425 not being readable. Under the 200-row ceiling, so a plan run grounded on **21 tables** (`ctx_c35a`) and drafted a `LEFT JOIN` over the real objects. **Connect as a least-privilege role, not as a superuser**: as `postgres` the run fails with *the agent cannot run on this database engine: it offers no read-only execution profile*, which is the profile refusing a superuser on any PostgreSQL rather than anything about ParadeDB. So B52's real trigger is a role that CAN read a wide catalog, not an extension that installs one. Slow queries do work here, unlike most PostgreSQL relatives, because `pg_stat_statements` ships enabled. **`version()` names PostgreSQL only**, so the version panel cannot tell this apart from a stock PostgreSQL 18.6 - the release is in the image tag and nowhere the provider reads. | +| ParadeDB | `postgres` | Full | ParadeDB 0.25.4 on PostgreSQL 18.6 | Probed 2026-08-27 against `paradedb/paradedb:0.25.4`. All fifteen surfaces answer and the numbers are correct (2000 rows read as 2000, 131072 bytes as 131072), with a foreign key and its indexes read back. **What it costs is width, not accuracy**: the object browser lists **41 objects for 2 user tables** and the index panel 74 rows, because ParadeDB ships nine extensions - `pg_search`, `vector`, `postgis` with its `tiger` geocoder, `pg_ivm`, `pg_stat_statements` and `paradedb`'s own tables. `spatial_ref_sys` (8500 rows) sits in `public` beside your tables. **Agent plan mode WORKS here, and that result refutes what the width predicted** - worth recording, because the expectation was that a wide catalog would refuse the grounding read: the grounding capture reads what the ROLE can see, and a superuser sees 539 non-system columns where an unprivileged role sees **168**, the tiger geocoder's 425 not being readable. Under the 200-row ceiling, so a plan run grounded on **21 tables** (`ctx_c35a`) and drafted a `LEFT JOIN` over the real objects. **Connect as a least-privilege role, not as a superuser**: as `postgres` the run fails with *the agent cannot run on this database engine: it offers no read-only execution profile*, which is the profile refusing a superuser on any PostgreSQL rather than anything about ParadeDB. So the real trigger is a role that CAN read a wide catalog, not an extension that installs one. Slow queries do work here, unlike most PostgreSQL relatives, because `pg_stat_statements` ships enabled. **`version()` names PostgreSQL only**, so the version panel cannot tell this apart from a stock PostgreSQL 18.6 - the release is in the image tag and nowhere the provider reads. | | Percona Distribution for PostgreSQL | `postgres` | Full | Percona Server for PostgreSQL 18.6.1 on PostgreSQL 18.6 | Probed 2026-08-26 against `percona/percona-distribution-postgresql:18.6`. Behaves as PostgreSQL throughout: all fifteen surfaces answer, **the numbers are correct** (2000 rows read as 2000 and 131072 bytes as 131072, index 65536), a foreign key is read back with its indexes, and `Analyze` and `Vacuum` both work. **The version panel names Percona** - `version()` answers *PostgreSQL 18.6 - Percona Server for PostgreSQL 18.6.1* - which is the opposite of the MySQL distribution below and worth knowing when you are trying to tell a fork from stock. Slow queries are empty until `pg_stat_statements` is enabled, which is stock PostgreSQL behaviour rather than a Percona property. Nothing else deviates from the PostgreSQL baseline. | | Citus | `postgres` | Full | citus 14.1-1 on PostgreSQL 18.4 | All surfaces answer. **Row counts and sizes for a distributed table are wrong, not missing** — PostgreSQL statistics describe the empty coordinator parent, not the shards. `citus_tables` and `citus_schemas` show up in the browser. | | OrioleDB | `postgres` | Full | OrioleDB beta 16 on PostgreSQL 18.4 (nightly of 2026-08-24) | Probed 2026-08-27 against `orioledb/orioledb:pg18-nightly-20260824-cc35a80-ubuntu`. **Read this row against ParadeDB above: both are Full and their costs are opposites.** Here the object browser is clean - 2 objects for 2 user tables - row counts are exact (2000 read as 2000, 122880 bytes), and a foreign key and its indexes are read back. What is missing is what PostgreSQL cannot see of OrioleDB's own storage: **every index reads 0 bytes** in the browser and the table statistics, because `pg_indexes_size()` returns 0 for an OrioleDB table (measured directly: 114688 bytes of table, 0 of indexes) - the YugabyteDB DocDB shape; and **the cache hit ratio reads N/A**, because OrioleDB has its own buffer manager and `pg_statio_user_tables` stays at 0 hits / 0 reads. That second one is an absence honestly rendered, not a wrong number. **Gate 7 passes** under a least-privilege role: 27 visible columns, a plan run grounded on 6 tables (`ctx_6b5e`) and drafted a `LEFT JOIN` over the real objects - and as a superuser it fails on the same profile refusal ParadeDB's row describes. Verified before trusting the row that the fixture actually measures OrioleDB: `default_table_access_method` is `orioledb` and both probe tables report `amname = orioledb`, so a heap-table probe measuring plain PostgreSQL was ruled out. **`version()` does name OrioleDB** - the one thing ParadeDB's does not - and carries the build hash and date, which is also the caveat: the project publishes **nightly images only**, so there is no release tag to pin and this row describes one dated build. | -| TimescaleDB | `postgres` | Full | TimescaleDB 2.29.2 on PostgreSQL 17.11 | All surfaces answer. **A hypertable's row count and size are wrong, not missing** — the statistics describe the empty parent table, not the chunks. Every chunk shows up as its own table and index, along with the `_timescaledb_catalog` and `_timescaledb_cache` schemas. The overview shows PostgreSQL's version, not the extension's. **The agent cannot ground a run here** — the extension's catalogs answer 473 of 478 rows in the grounding read, over its 200-row budget, on a stock install. | +| TimescaleDB | `postgres` | Full | TimescaleDB 2.29.2 on PostgreSQL 17.11 | All surfaces answer. **A hypertable's row count and size are wrong, not missing** — the statistics describe the empty parent table, not the chunks. Every chunk shows up as its own table and index, along with the `_timescaledb_catalog` and `_timescaledb_cache` schemas. The overview shows PostgreSQL's version, not the extension's. **The agent now grounds a stock install** — the grounding read aggregates columns per table instead of refusing past the 200-row budget, and every chunk still appears as its own table in the inventory. | | YugabyteDB | `postgres` | Full | YugabyteDB 2.25.2.0-b0 (advertises PostgreSQL 15.12) | All surfaces answer, foreign keys included. **Row counts and sizes read 0 until you run `ANALYZE`** — nothing collects statistics automatically, so a full database looks empty. Index sizes always read 0 bytes (index storage lives in DocDB) and the overview's database size reads 0 bytes. Index types read `lsm`, which is the real storage rather than a misreading. | -| AlloyDB Omni | `postgres` | Full | PostgreSQL 17.9 (AlloyDB Omni 17.9.0) | All fifteen surfaces answer, and the numbers are exact: 2000 rows read as 2000 and 270336 bytes as 270336 (180224 table plus 90112 index), with a foreign key both read back and enforced by the engine. **The version panel cannot be told apart from a stock PostgreSQL 17** - `version()` reports `PostgreSQL 17.9 on x86_64-pc-linux-gnu` and names AlloyDB nowhere; the product is identifiable only from the `alloydb.*` settings and the image tag. The object browser lists 10 objects for 2 user tables, the 8 extras being AlloyDB's own `google_ml` tables - and **that understates what is installed**: outside the system schemas there are 70 objects, because 49 extension views live in `public` itself (`g_columnar_*`, `google_db_advisor_*`, `hypopg_list_indexes` and more), which the browser hides only because its schema query filters `table_type = 'BASE TABLE'`. A role with no grants at all - `LOGIN` plus `CONNECT`, `ALL` revoked on `public` - still lists those `google_ml` tables and reads them (`SELECT count(*) FROM google_ml.supported_vertex_models` answered 15 to it). The slow-query panel is empty because `pg_stat_statements` ships with the image but is not installed in it, which health reports honestly. The columnar engine is off by default and needs `ALTER SYSTEM` plus a restart; with it on the on-disk sizes stay exact but do not count the columnar copy. **The agent cannot ground a run here**: the image's own `postgres` superuser is refused as too broad, and a least-privilege role - which does acquire both profiles - has its capture refused at 536 rows against a 200-row budget, only 7 of them the user's, and narrowing the capture to `public` alone still refuses at 348 (B52). Probed on the 17.9.0 image only; the 15.x and 16.x lines were not. | +| AlloyDB Omni | `postgres` | Full | PostgreSQL 17.9 (AlloyDB Omni 17.9.0) | All fifteen surfaces answer, and the numbers are exact: 2000 rows read as 2000 and 270336 bytes as 270336 (180224 table plus 90112 index), with a foreign key both read back and enforced by the engine. **The version panel cannot be told apart from a stock PostgreSQL 17** - `version()` reports `PostgreSQL 17.9 on x86_64-pc-linux-gnu` and names AlloyDB nowhere; the product is identifiable only from the `alloydb.*` settings and the image tag. The object browser lists 10 objects for 2 user tables, the 8 extras being AlloyDB's own `google_ml` tables - and **that understates what is installed**: outside the system schemas there are 70 objects, because 49 extension views live in `public` itself (`g_columnar_*`, `google_db_advisor_*`, `hypopg_list_indexes` and more), which the browser hides only because its schema query filters `table_type = 'BASE TABLE'`. A role with no grants at all - `LOGIN` plus `CONNECT`, `ALL` revoked on `public` - still lists those `google_ml` tables and reads them (`SELECT count(*) FROM google_ml.supported_vertex_models` answered 15 to it). The slow-query panel is empty because `pg_stat_statements` ships with the image but is not installed in it, which health reports honestly. The columnar engine is off by default and needs `ALTER SYSTEM` plus a restart; with it on the on-disk sizes stay exact but do not count the columnar copy. **The agent needs a least-privilege role to ground a run** — the image's own `postgres` superuser is refused as too broad; with a least-privilege role the capture succeeds (the grounding read aggregates columns per table), and the inventory includes the 49 extension views installed into `public`. Probed on the 17.9.0 image only; the 15.x and 16.x lines were not. | | Valkey | `redis` | Full | Valkey 9.1.1 | Behaves as Redis. The overview shows the Redis emulation level (7.2.4), not the Valkey version. | | DragonflyDB | `redis` | Full | DragonflyDB df-v1.40.1 | Overview shows the emulation level (7.4.0). Max connections reads 0 (no usable `maxclients` in `INFO`), active sessions show a numeric id instead of a username (`CLIENT LIST` sets `name=` to the connection id), and every session reads `idle`/`N` (no `cmd=` or `flags=` field). | | KeyDB | `redis` | Full | KeyDB 6.3.4 | Publishes no version field of its own, so the overview is indistinguishable from a Redis 6 server. A session's command can appear without its subcommand. | @@ -80,7 +80,7 @@ the support column records how much of the product actually worked: | OceanBase | `mysql` | Partial | 5.7.25-OceanBase_CE-v4.4.2.1 | Fourteen of the fifteen surfaces return without throwing, but **only twelve of them do their job**, and that gap is what makes this partial rather than full: performance metrics and storage stats are answered-but-useless, and the overview, table statistics and index statistics carry useless zeros in their size fields while their row and structure data is real. The slow-query panel read 0 rows here, an honest empty rather than a fabricated number; since #512 it shows the tenant's own `ERROR 1049` instead, because `getSlowQueries()` no longer swallows an unreadable source. **Health is the one hard failure** - the tenant has no `performance_schema` database at all (`ERROR 1049 Unknown database 'performance_schema'`), and health passes on the MySQL 26.7.0 baseline probed in the same pass, so this is the engine's. One consequence is visible before any panel is opened: the header badge reads **Slow** rather than Online, which is not latency - the badge is set from whether the health request succeeded, and health is exactly what this engine refuses. **Every size reads 0 B** - table, index, database and storage - because `information_schema.TABLES` reports `DATA_LENGTH 0` and `INDEX_LENGTH 0`; storage stats also list a phantom `InnoDB` row at `ibdata1:12M:autoextend` whose size reads N/A, OceanBase faking `innodb_data_file_path` for compatibility with no such file behind it. **Row counts are correct** once `ANALYZE TABLE` has run (2000 for 2000), and it must be the MySQL-mode statement - the Oracle-mode `ANALYZE TABLE t COMPUTE STATISTICS` is rejected with `ERROR 1235`. The object browser is clean, 2 objects for 2 user tables: the schema query scopes to `TABLE_SCHEMA` and to `TABLE_TYPE = 'BASE TABLE'`, and OceanBase's own 860 + 70 + 18 catalog objects are all `SYSTEM TABLE` or `SYSTEM VIEW`, so neither filter admits them. A foreign key is both read back and enforced (an orphan insert is refused with `ERROR 1452`), but no backing index is created for one, so index counts will not match an equivalent MySQL schema. Cancelling genuinely cancels, and Explain genuinely describes without executing (an 11-row ASCII plan with an `EST.ROWS` column). **Sign in to the business tenant, not `sys`**: `MODE=mini` creates a user tenant named `test`, so the login is `root@test`; the `sys` tenant shows nine databases including Oracle-mode artifacts (`LBACSYS`, `ORAAUDITOR`, `SYS`, `ocs`, `sys_external_tbs`) that a user tenant does not have. Plan mode grounds a run here, through the provider-inventory path. Probed on the `4.4.2-lts` image with `MODE=mini` only. | | SingleStore | `mysql` | Partial | SingleStoreDB 9.1.1 (advertises MySQL 5.7.32) | Ten of the fifteen surfaces answer. **Four of those failures were ours, not SingleStore's, and are fixed** - Test Connection, health, the overview and the monitoring dashboard all failed with the same engine message, `This command is not supported in the prepared statement protocol yet`, because the provider sent every statement through mysql2's binary prepared protocol. Every parameterless statement moved to the text protocol on 2026-08-24, and all four were re-probed in the browser on the built app: the header badge reads **Online**, the fleet card reads `152ms / 4 conn`, and Monitoring renders `MySQL 5.7.32`, an uptime, `Connections 7/100000` and `Tables 2 / 2 indexes`. The Storage panel renders too, all zeros, which is this engine's own zeroed `information_schema.TABLES` below and not a failure of ours. **The Explain panel is NOT among them**: `EXPLAIN FORMAT=JSON` is `ER_PARSE_ERROR` here on BOTH protocols, because SingleStore's grammar is `EXPLAIN JSON` - a statement problem wearing a protocol problem's message, recorded as X14 in [`../BACKLOG.md`](../BACKLOG.md). **Row counts and sizes are missing rather than wrong**: a 2000-row table reads `rowCount 0` and `0 B` in the object browser, the table statistics and the storage panel, against a ground truth of 2000 rows and 77046 bytes measured four independent ways (`SELECT count(*)`, `SHOW TABLE STATUS`, `information_schema.OPTIMIZER_STATISTICS.ROW_COUNT`, and the EXPLAIN plan's `est_table_rows`). SingleStore leaves `information_schema.TABLES` zeroed and keeps the real numbers elsewhere, and running `ANALYZE` does not change what the panels read, so a populated table looks empty. The index panel lists 4 rows for 2 tables against the baseline's 2, because SingleStore auto-creates a shard key on every table and reports it as an index named `__SHARDKEY` with index type `SHARD`, beside `PRIMARY`. **Foreign keys do not exist**: `ALTER TABLE ... ADD FOREIGN KEY` fails with `ERROR 2752`, and with `SET GLOBAL ignore_foreign_keys = ON` a `CREATE TABLE` carrying an inline foreign key is accepted and the constraint silently stripped - the one shape where you could believe you have a key you do not. Of the maintenance actions Analyze always worked; Optimize and Check failed with the same prepared-statement error and both succeed on the text protocol, measured through the provider (the app offers only Analyze as a global action here, so that pair was not re-probed from the UI). Permission errors are identical to MySQL, with the server's own text intact, and under a `SELECT`-only role the table list correctly showed only the granted table. No version is displayed anywhere, because the panel carrying it is one of the unavailable ones; were it fixed it would read MySQL 5.7.32, the wire version, not SingleStoreDB 9.1.1. **No licence key is needed** - the dev image self-licenses with `ROOT_PASSWORD` as the only variable set, which is what issue #424 recorded as the reason this engine had gone unprobed. Plan mode grounds a run here, through the provider-inventory path. Probed on the `0.2.82` dev image only. | | CockroachDB | `postgres` | Partial | CockroachDB CCL v26.2.5 | Editor, error handling, performance metrics, slow queries and sessions all work. The **object browser and every size/health panel are blank**: `pg_total_relation_size()`, `pg_size_pretty()`, `pg_postmaster_start_time()` and `pg_tablespace_location()` do not exist there. | -| Apache Cloudberry (incubating) | `postgres` | Partial | PostgreSQL 14.4 (Apache Cloudberry 2.1.0-incubating) | Twelve of the fifteen surfaces answer. The monitoring dashboard, table statistics and index statistics all fail with one engine error, `query plan with multiple segworker groups is not supported`, which is Cloudberry's MPP planner restriction rather than a version gap. **Row counts and sizes after `ANALYZE` are correct** (2000 rows for 2000; 576 KB for 589824 bytes), so this is not the kind of engine whose statistics mislead; what they read before `ANALYZE` was not probed. Two `pg_ext_aux` tables appear in the browser, so it lists 4 objects for 2 user tables, and the overview's database size reads 62 MB against roughly 900 KB of user tables. **A foreign key is read back as if enforced and is not**: Cloudberry accepts the constraint with a warning that it will not enforce it, and an orphan insert then succeeds. **The agent cannot ground a run here**: the usual `gpadmin` login is refused because the execution profile reads a superuser as too broad, and a least-privilege role is refused at 289 rows against a 200-row budget, 282 of them in Cloudberry's own `gp_toolkit`. What the run reports for the first of those is that the engine offers no read-only execution profile, which describes the role rather than the engine (B47). The three failing panels report the planner error as a connection error, which the connection is not. Apache publishes build images only, so the probe ran on a third-party image. | +| Apache Cloudberry (incubating) | `postgres` | Partial | PostgreSQL 14.4 (Apache Cloudberry 2.1.0-incubating) | Twelve of the fifteen surfaces answer. The monitoring dashboard, table statistics and index statistics all fail with one engine error, `query plan with multiple segworker groups is not supported`, which is Cloudberry's MPP planner restriction rather than a version gap. **Row counts and sizes after `ANALYZE` are correct** (2000 rows for 2000; 576 KB for 589824 bytes), so this is not the kind of engine whose statistics mislead; what they read before `ANALYZE` was not probed. Two `pg_ext_aux` tables appear in the browser, so it lists 4 objects for 2 user tables, and the overview's database size reads 62 MB against roughly 900 KB of user tables. **A foreign key is read back as if enforced and is not**: Cloudberry accepts the constraint with a warning that it will not enforce it, and an orphan insert then succeeds. **The agent needs a least-privilege role to ground a run** — the usual `gpadmin` login is refused because the execution profile reads a superuser as too broad; with a least-privilege role the capture succeeds (the grounding read aggregates columns per table). What the run reports for the first of those is that the engine offers no read-only execution profile, which describes the role rather than the engine (B47). The three failing panels report the planner error as a connection error, which the connection is not. Apache publishes build images only, so the probe ran on a third-party image. | | ScyllaDB | `cassandra` | Partial | ScyllaDB 2026.2.4-0.20260810.e54224b8cebb (advertises Cassandra 3.0.8) | **All thirteen surfaces this provider offers now answer** - thirteen rather than the fifteen this column counts elsewhere, because the Cassandra provider offers neither cancellation nor `EXPLAIN` on either engine - and the row is still Partial because five of them answer *empty*. Re-probed 2026-08-24 through `createDatabaseProvider({type:"cassandra"})`, surface by surface, after the 2026-08-24 change: the overview, health, performance metrics, active sessions and the monitoring dashboard read Cassandra's `system_views` virtual tables, ScyllaDB has no `system_views` keyspace at all, and all five used to come back with the same verbatim `Keyspace system_views does not exist`. They now degrade to empty the way a denied grant does, so **Test Connection passes and the connection dialog can create a ScyllaDB connection** - before that change it could not, because Establish Connection is gated on that same health request, and one had to arrive seeded or admin-managed. What the degradation costs: no cache hit ratio and no active-session list (both `N/A`). **Connections now reads `N/A` with "not published" beneath it, not a fabricated `0`** - `DatabaseOverview.activeConnections` and `HealthInfo.activeConnections` are both optional as of 2026-08-24, the provider omits the key rather than send a zero nobody measured, and the same absence reaches the fleet card (which drops the connection chip entirely) and the agent's curated health reading (`null`). Verified in the browser on the built app: Connections `N/A / not published` on ScyllaDB against `1 / no limit published` on Apache Cassandra 5.0.9 in the same pass. The version, uptime, table count and index count are real: measured `Apache Cassandra 3.0.8`, `23.76m`, 3 tables, 1 index. Apache Cassandra 5.0.9, probed in the same pass, answers all thirteen with data in every one. **The SQL editor and the object browser work in full**: statements run, and every one of 18 CQL types read back byte-identically to that Cassandra baseline, `bigint` 9007199254740993, `decimal` 1.25, `duration` `3h20m`, `varint`, `blob`, `inet`, `date`, `time` and the collections included. The version panel reads Apache Cassandra 3.0.8 - the compatibility number `system.local` publishes - and not ScyllaDB 2026.2.4, which lives in `system.versions` where the provider does not look. **The object browser lists one extra object per secondary index, and the tree and the overview disagree about it**: ScyllaDB backs an index with a materialized view, so `customers_country_idx_index` is in `system_schema.views` - which the tree reads - and not in `system_schema.tables`, which the Tables count reads (measured: 4 objects in the tree against `tableCount` 3). Cassandra lists neither. **Error classes are identical to Cassandra even though the server's wording is not** - a missing table is `unconfigured table no_such_table` rather than `table no_such_table does not exist`, a missing column `Unrecognized name nope` rather than `Undefined column name nope in table probe.customers` - because the provider classifies on the driver's error code and not on the message text. Row counts and sizes are blank for the same reason as on Cassandra, and the panels read `N/A` rather than a fabricated zero. **Creating a keyspace needs `NetworkTopologyStrategy` on the 2026.2 line**: `SimpleStrategy` is refused outright with `SimpleStrategy doesn't support tablet replication`, so the setup recipe in [cassandra.md §10](./cassandra.md#reproducing-the-live-pass) does not run unchanged; 2025.1 still accepts it, with a warning. ScyllaDB 2025.1.14-0.20260612.103b84070f3b was probed in the 2026-08-21/22 pass and behaved identically on every surface, including the same verbatim refusal the fix keys on, so this row describes both the 2025.1 and the 2026.2 line - but only those two builds, only a single-node container, and only 2026.2.4 was re-probed after the fix. | | Databend | `mysql` | Query editor only | Databend v1.2.925-patch-11 (advertises MySQL 8.0.90) | Probed 2026-08-27 against `datafuselabs/databend:v1.2.925-patch-11`. SQL runs - a 2000-row `count(*)`, a `GROUP BY` and a plain `EXPLAIN` all answer, the last with Databend's own `TableScan` plan. **Everything else is unavailable for a reason that is ours, and the catalogs prove it**: asked with literal SQL, `information_schema.tables` reports the true 3 and 2000 rows with `data_length` 124 and 49000, and `information_schema.columns` answers in full. The provider gets none of it, because every parameterised read goes through mysql2's prepared protocol and Databend replies **`Prepare is not support in Databend`** - so the table list, the schema, active sessions and the table, index and storage statistics all fail. [D8](../BACKLOG.md) moved the *parameterless* statements to the text protocol; these carry placeholders and still prepare. Recorded as [D33](../BACKLOG.md). Two gaps are the engine's own and would survive that fix: **there is no `SHOW STATUS` statement at all** (`unexpected STATUS. Did you mean SHOW STAGES...`), so the overview and health panels have no source, and there is no `information_schema.processlist`, so sessions have none either. Also its own: `EXPLAIN FORMAT='json'` does not parse, `OPTIMIZE` and `CHECK` do not exist, foreign keys are absent (`key_column_usage` is empty) and no index is ever reported (`statistics` is empty). **`ANALYZE` crashes the provider rather than failing**: it returns an object where the reader expects an array, so `runMaintenance` throws `rows.filter is not a function` - filed with D33. One authoring trap worth knowing: Databend follows the SQL standard on quoting, so `"TR"` is an identifier and only `'TR'` is a string. **Browser-verified 2026-08-27**: the editor really does run - `SELECT country, count(*) ... GROUP BY country` returned `DE 1`, `TR 1`, `US 1` from the grid - which is the check QuestDB failed, and the background `EXPLAIN FORMAT=JSON` beside it returns 500 as expected. **What plan mode does here is the honest downstream of the same cause and is worth reading**: the run succeeds and drafts NOTHING, saying *this run was given no inventory of this database* and asking for the table and column names - the schema read failed, so it refuses to invent a schema rather than guessing one. | | Materialize | `postgres` | Query editor only | Materialize 26.37.0 | No pg statistics catalog and no size functions, and `MATERIALIZED` is reserved, which our schema query uses. Editor only. | From 9a81b26c5d2ee14f8ec4dc61739b3711fa9ee85d Mon Sep 17 00:00:00 2001 From: cevheri Date: Fri, 4 Sep 2026 08:56:50 +0300 Subject: [PATCH 3/3] fix(agent): drop non-object entries from the aggregated column array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Array.isArray` says nothing about what is IN the array, so a `null` element reached the fold and was read as `column.name` — a TypeError raised where `buildPostgresTables` runs OUTSIDE `readInventory`'s catch, which ends the run `internal` instead of degrading the capture. That is the shape B48 exists to keep out of this path, and the opposite of what `parsePostgresColumns` documents. Filter the elements, dropping the bad ones rather than emptying the list: the rest of the array is still an inventory. Also, three records that the row-cap fix left inconsistent with what was measured: - The two measurements are stated with what each was taken UNDER (role and date). 478/289/536 and 46/67/70 are both true and mean nothing without their basis. - Record what was NOT measured: the aggregate runs on Cloudberry's MPP planner, but Materialize and RisingWave are jsonb-only and may carry no `json_agg` at all. Both are query-only and claim no grounding, so nothing promised regressed. - B76's 67 AlloyDB views are attributed to where they sit — 49 in `public`, 14 in `google_ml`, 4 in `ai` — rather than reading as if `ai.*` were the bulk. The user-facing caveats drop `(B52)`, whose entry no longer exists, and the temporal "now". `docs/AGENT.md`'s table of contents is restored: an auto-TOC pass had rewritten it to list the document's own title and its own contents heading. The composed SQL itself is unchanged. Verified live on the three images the PR names: TimescaleDB 387 flat rows to 46 objects, Cloudberry 481 to 67 as gpadmin, AlloyDB Omni 544 to 70 as superuser and 536 to 69 as a least-privilege role. --- docs/AGENT.md | 47 ++++++----------- docs/BACKLOG.md | 6 ++- docs/providers/README.md | 2 +- src/lib/agent/composed-sql.ts | 20 ++++++-- src/lib/agent/context-snapshot.ts | 19 +++++-- src/lib/db/compatibility.ts | 6 +-- tests/unit/lib/agent/context-snapshot.test.ts | 50 ++++++++++++++++++- 7 files changed, 103 insertions(+), 47 deletions(-) diff --git a/docs/AGENT.md b/docs/AGENT.md index 7ea25733..38e49ddb 100644 --- a/docs/AGENT.md +++ b/docs/AGENT.md @@ -84,38 +84,21 @@ Two companion pages carry what this one deliberately does not: ## Table of Contents -- [Agent Runtime — LibreDB Studio](#agent-runtime--libredb-studio) - - [Table of Contents](#table-of-contents) - - [Turning it on](#turning-it-on) - - [What a run is](#what-a-run-is) - - [The conversation a run belongs to](#the-conversation-a-run-belongs-to) - - [What a plan run knows](#what-a-plan-run-knows) - - [What the inventory is an inventory OF](#what-the-inventory-is-an-inventory-of) - - [The statement a plan run drafts](#the-statement-a-plan-run-drafts) - - [Durability and resume](#durability-and-resume) - - [A drive that dies before the loop](#a-drive-that-dies-before-the-loop) - - [The tool set](#the-tool-set) - - [The query-optimization template](#the-query-optimization-template) - - [The database-assessment template](#the-database-assessment-template) - - [The operations template](#the-operations-template) - - [The data-analysis template](#the-data-analysis-template) - - [Presenting an answer](#presenting-an-answer) - - [Handing the answer to the editor (auto-execute)](#handing-the-answer-to-the-editor-auto-execute) - - [What the fence is proved to hold against](#what-the-fence-is-proved-to-hold-against) - - [What bounds a run](#what-bounds-a-run) - - [Supported models](#supported-models) - - [The model side](#the-model-side) - - [What a refused model looks like in the app](#what-a-refused-model-looks-like-in-the-app) - - [Whether the run answered](#whether-the-run-answered) - - [The eval harness](#the-eval-harness) - - [What the removed AI panels did that a run does not](#what-the-removed-ai-panels-did-that-a-run-does-not) - - [HTTP surface](#http-surface) - - [The surface in the app](#the-surface-in-the-app) - - [Deployment](#deployment) - - [Package boundary](#package-boundary) - - [Module map](#module-map) - - [Known limitations](#known-limitations) - - [Related documentation](#related-documentation) +- [Turning it on](#turning-it-on) +- [What a run is](#what-a-run-is) +- [Durability and resume](#durability-and-resume) +- [The tool set](#the-tool-set) +- [What bounds a run](#what-bounds-a-run) +- [Supported models](#supported-models) +- [The model side](#the-model-side) +- [Whether the run answered](#whether-the-run-answered) +- [What the removed AI panels did that a run does not](#what-the-removed-ai-panels-did-that-a-run-does-not) +- [HTTP surface](#http-surface) +- [The surface in the app](#the-surface-in-the-app) +- [Deployment](#deployment) +- [Package boundary](#package-boundary) +- [Module map](#module-map) +- [Known limitations](#known-limitations) ## Turning it on diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 5651c582..d71dd282 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -1977,8 +1977,10 @@ is that it succeeds by admitting the image's own objects. Measured live on 2026- - Cloudberry (`woblerr/cloudberry:2.1.0-incubating`): 479 to 67 as gpadmin, 53 as a least-privilege role. - AlloyDB Omni (`google/alloydbomni:17.9.0`): 542 to 70 as postgres, 69 as a least-privilege role. -AlloyDB is the sharp case: the least-privilege role's 69 tables are 2 user tables plus 67 `ai.*`, -`google_db_advisor_*` and `hypopg_list_indexes` extension views. Not a privilege leak — +AlloyDB is the sharp case: the least-privilege role's 69 tables are 2 user tables plus 67 extension views, +and where they SIT is the part that makes them noise rather than a schema to exclude — 49 of them are +installed into `public` itself (`google_db_advisor_*`, `hypopg_list_indexes`), beside 14 in `google_ml` and +4 in `ai`. Not a privilege leak — `information_schema.columns` applies its own visibility rules and the role genuinely sees those views — but grounding noise: the inventory a run reasons over is mostly objects the user did not create, and the model-facing pack spends `AGENT_CONTEXT_PACK_MAX_CHARS` ranking them against the objective. The old flat diff --git a/docs/providers/README.md b/docs/providers/README.md index 96860968..0c4bab78 100644 --- a/docs/providers/README.md +++ b/docs/providers/README.md @@ -67,7 +67,7 @@ the support column records how much of the product actually worked: | Percona Distribution for PostgreSQL | `postgres` | Full | Percona Server for PostgreSQL 18.6.1 on PostgreSQL 18.6 | Probed 2026-08-26 against `percona/percona-distribution-postgresql:18.6`. Behaves as PostgreSQL throughout: all fifteen surfaces answer, **the numbers are correct** (2000 rows read as 2000 and 131072 bytes as 131072, index 65536), a foreign key is read back with its indexes, and `Analyze` and `Vacuum` both work. **The version panel names Percona** - `version()` answers *PostgreSQL 18.6 - Percona Server for PostgreSQL 18.6.1* - which is the opposite of the MySQL distribution below and worth knowing when you are trying to tell a fork from stock. Slow queries are empty until `pg_stat_statements` is enabled, which is stock PostgreSQL behaviour rather than a Percona property. Nothing else deviates from the PostgreSQL baseline. | | Citus | `postgres` | Full | citus 14.1-1 on PostgreSQL 18.4 | All surfaces answer. **Row counts and sizes for a distributed table are wrong, not missing** — PostgreSQL statistics describe the empty coordinator parent, not the shards. `citus_tables` and `citus_schemas` show up in the browser. | | OrioleDB | `postgres` | Full | OrioleDB beta 16 on PostgreSQL 18.4 (nightly of 2026-08-24) | Probed 2026-08-27 against `orioledb/orioledb:pg18-nightly-20260824-cc35a80-ubuntu`. **Read this row against ParadeDB above: both are Full and their costs are opposites.** Here the object browser is clean - 2 objects for 2 user tables - row counts are exact (2000 read as 2000, 122880 bytes), and a foreign key and its indexes are read back. What is missing is what PostgreSQL cannot see of OrioleDB's own storage: **every index reads 0 bytes** in the browser and the table statistics, because `pg_indexes_size()` returns 0 for an OrioleDB table (measured directly: 114688 bytes of table, 0 of indexes) - the YugabyteDB DocDB shape; and **the cache hit ratio reads N/A**, because OrioleDB has its own buffer manager and `pg_statio_user_tables` stays at 0 hits / 0 reads. That second one is an absence honestly rendered, not a wrong number. **Gate 7 passes** under a least-privilege role: 27 visible columns, a plan run grounded on 6 tables (`ctx_6b5e`) and drafted a `LEFT JOIN` over the real objects - and as a superuser it fails on the same profile refusal ParadeDB's row describes. Verified before trusting the row that the fixture actually measures OrioleDB: `default_table_access_method` is `orioledb` and both probe tables report `amname = orioledb`, so a heap-table probe measuring plain PostgreSQL was ruled out. **`version()` does name OrioleDB** - the one thing ParadeDB's does not - and carries the build hash and date, which is also the caveat: the project publishes **nightly images only**, so there is no release tag to pin and this row describes one dated build. | -| TimescaleDB | `postgres` | Full | TimescaleDB 2.29.2 on PostgreSQL 17.11 | All surfaces answer. **A hypertable's row count and size are wrong, not missing** — the statistics describe the empty parent table, not the chunks. Every chunk shows up as its own table and index, along with the `_timescaledb_catalog` and `_timescaledb_cache` schemas. The overview shows PostgreSQL's version, not the extension's. **The agent now grounds a stock install** — the grounding read aggregates columns per table instead of refusing past the 200-row budget, and every chunk still appears as its own table in the inventory. | +| TimescaleDB | `postgres` | Full | TimescaleDB 2.29.2 on PostgreSQL 17.11 | All surfaces answer. **A hypertable's row count and size are wrong, not missing** — the statistics describe the empty parent table, not the chunks. Every chunk shows up as its own table and index, along with the `_timescaledb_catalog` and `_timescaledb_cache` schemas. The overview shows PostgreSQL's version, not the extension's. **The agent grounds a stock install** — the grounding read aggregates columns per table instead of refusing past the 200-row budget, and every chunk still appears as its own table in the inventory. | | YugabyteDB | `postgres` | Full | YugabyteDB 2.25.2.0-b0 (advertises PostgreSQL 15.12) | All surfaces answer, foreign keys included. **Row counts and sizes read 0 until you run `ANALYZE`** — nothing collects statistics automatically, so a full database looks empty. Index sizes always read 0 bytes (index storage lives in DocDB) and the overview's database size reads 0 bytes. Index types read `lsm`, which is the real storage rather than a misreading. | | AlloyDB Omni | `postgres` | Full | PostgreSQL 17.9 (AlloyDB Omni 17.9.0) | All fifteen surfaces answer, and the numbers are exact: 2000 rows read as 2000 and 270336 bytes as 270336 (180224 table plus 90112 index), with a foreign key both read back and enforced by the engine. **The version panel cannot be told apart from a stock PostgreSQL 17** - `version()` reports `PostgreSQL 17.9 on x86_64-pc-linux-gnu` and names AlloyDB nowhere; the product is identifiable only from the `alloydb.*` settings and the image tag. The object browser lists 10 objects for 2 user tables, the 8 extras being AlloyDB's own `google_ml` tables - and **that understates what is installed**: outside the system schemas there are 70 objects, because 49 extension views live in `public` itself (`g_columnar_*`, `google_db_advisor_*`, `hypopg_list_indexes` and more), which the browser hides only because its schema query filters `table_type = 'BASE TABLE'`. A role with no grants at all - `LOGIN` plus `CONNECT`, `ALL` revoked on `public` - still lists those `google_ml` tables and reads them (`SELECT count(*) FROM google_ml.supported_vertex_models` answered 15 to it). The slow-query panel is empty because `pg_stat_statements` ships with the image but is not installed in it, which health reports honestly. The columnar engine is off by default and needs `ALTER SYSTEM` plus a restart; with it on the on-disk sizes stay exact but do not count the columnar copy. **The agent needs a least-privilege role to ground a run** — the image's own `postgres` superuser is refused as too broad; with a least-privilege role the capture succeeds (the grounding read aggregates columns per table), and the inventory includes the 49 extension views installed into `public`. Probed on the 17.9.0 image only; the 15.x and 16.x lines were not. | | Valkey | `redis` | Full | Valkey 9.1.1 | Behaves as Redis. The overview shows the Redis emulation level (7.2.4), not the Valkey version. | diff --git a/src/lib/agent/composed-sql.ts b/src/lib/agent/composed-sql.ts index f485503d..2f2870a7 100644 --- a/src/lib/agent/composed-sql.ts +++ b/src/lib/agent/composed-sql.ts @@ -159,17 +159,27 @@ function equalsClause(column: string, value: string | undefined, field: string, * * The projection used to be one row per COLUMN. Against `maxResultRows: 200` that * refused an unnarrowed capture on any PostgreSQL image whose own catalogs are wide - * before the user creates anything — measured on TimescaleDB (478 rows), Cloudberry - * (289) and AlloyDB Omni (536), where the user's own tables were a handful of those - * rows. Aggregating the columns per table makes the projection one row per OBJECT, - * symmetric with the SQLite side, so a stock image answers a few dozen rows instead - * of a few hundred. + * before the user creates anything. Two measurements exist and they disagree by + * design, so each is stated with what it was taken UNDER: as a least-privilege agent + * role on 2026-08-20, TimescaleDB answered 478 flat rows, Cloudberry 289 and AlloyDB + * Omni 536; re-measured on 2026-09-01 with the aggregation in place and two seeded + * user tables, the same reads answer 46, 67 and 70 OBJECT rows. The totals are + * per-role and per-image and mean nothing without both. * * `json_agg … ORDER BY ordinal_position` keeps the column order, which is the one * property the flat projection used to guarantee. The object keys are the ones * `buildPostgresTables` reads (`name`, `type`, `nullable`), so the fold parses one * array per table instead of one row per column. * + * MEASURED ON, and only on, the three servers B52 named plus stock PostgreSQL: the + * aggregate runs on Cloudberry's MPP planner, which is the one that refuses other + * reads with `multiple segworker groups is not supported`. NOT measured on the + * jsonb-only relatives in `WIRE_COMPATIBLE_ENGINES` — Materialize and RisingWave + * have no `json` type and may not carry `json_agg`/`json_build_object` at all. Both + * are `query-only` there and claim no grounding, so nothing regressed that was + * promised; a run that ever claims grounding on either has to measure this read + * first. + * * The row budget now counts TABLES rather than columns, which is what a wide * catalog needs; two bounds still stand and are worth naming. A schema with more * than `maxResultRows` tables is still refused — B52 removes the per-column diff --git a/src/lib/agent/context-snapshot.ts b/src/lib/agent/context-snapshot.ts index dfd23c4d..ddcb3407 100644 --- a/src/lib/agent/context-snapshot.ts +++ b/src/lib/agent/context-snapshot.ts @@ -267,13 +267,26 @@ const qualified = (schema: unknown, table: unknown): string => `${text(schema)}. * Anything else is the empty inventory rather than an exception: a row this cannot * read says nothing about its columns, and refusing it here would turn one malformed * row into a lost snapshot. + * + * The ELEMENTS are filtered for the same reason the value is, and the filter is the + * load-bearing half: `Array.isArray` says nothing about what is IN the array, so a + * `null` element used to reach the fold and be read as `column.name` — a TypeError, + * raised where `buildPostgresTables` runs OUTSIDE `readInventory`'s catch, which ends + * the run `internal` rather than degrading the capture (the shape B48 exists to keep + * out of this path). A bad element is dropped rather than emptying the list: the rest + * of the array is still an inventory, and losing one entry says less than losing the + * table. */ function parsePostgresColumns(value: unknown): readonly Record[] { - if (Array.isArray(value)) return value as readonly Record[]; + const entries = (candidate: unknown): readonly Record[] => + Array.isArray(candidate) + ? candidate.filter((entry): entry is Record => typeof entry === "object" && entry !== null) + : []; + + if (Array.isArray(value)) return entries(value); if (typeof value === "string" && value.trim() !== "") { try { - const parsed: unknown = JSON.parse(value); - if (Array.isArray(parsed)) return parsed as readonly Record[]; + return entries(JSON.parse(value) as unknown); } catch { return []; } diff --git a/src/lib/db/compatibility.ts b/src/lib/db/compatibility.ts index 9bbc5c56..ecf1f6e0 100644 --- a/src/lib/db/compatibility.ts +++ b/src/lib/db/compatibility.ts @@ -243,7 +243,7 @@ export const WIRE_COMPATIBLE_ENGINES: readonly WireCompatibleEngine[] = [ "Row counts and sizes for a hypertable are wrong rather than missing: PostgreSQL statistics describe the empty parent table, not the chunks the rows live in.", "Every chunk of a hypertable appears as its own table and index, so the object browser fills with _timescaledb_internal chunks and the _timescaledb_catalog and _timescaledb_cache schemas.", "The overview shows the PostgreSQL version, not the TimescaleDB extension version.", - "The agent grounds a stock install now: the column capture is one row per table rather than one row per column against a 200-row budget (B52). On a database with real hypertable data every chunk still appears as its own table in the inventory.", + "The agent grounds a stock install: the column capture is one row per table rather than one row per column, so the extension's own catalogs no longer overflow the 200-row budget. On a database with real hypertable data every chunk still appears as its own table in the inventory.", ], }, { @@ -269,7 +269,7 @@ export const WIRE_COMPATIBLE_ENGINES: readonly WireCompatibleEngine[] = [ "Two internal tables appear in the object browser, pg_ext_aux.pg_pax_fastsequence and pg_ext_aux.pg_pax_tables, so it lists 4 objects for 2 user tables.", "A foreign key is read back as if it were enforced but is not: Cloudberry accepts ALTER TABLE ... ADD CONSTRAINT with a warning that referential integrity constraints are not supported, and an orphan insert then succeeds.", "The overview's database size reads 62 MB against roughly 900 KB of user tables, which is catalog and segment overhead rather than your data.", - "The agent needs a least-privilege agent role to ground a run: connecting as the cluster's own gpadmin is refused because the execution profile reads that role as too broad. With that role the capture succeeds (B52).", + "The agent needs a least-privilege agent role to ground a run: connecting as the cluster's own gpadmin is refused because the execution profile reads that role as too broad. With that role the capture succeeds.", "Apache publishes build images only, so the probe ran on a third-party image (woblerr/cloudberry:2.1.0-incubating); no image from the project itself was measured.", ], }, @@ -286,7 +286,7 @@ export const WIRE_COMPATIBLE_ENGINES: readonly WireCompatibleEngine[] = [ "Those eight google_ml tables are readable by a role with no grants at all: a LOGIN role given only CONNECT, with ALL revoked on schema public, still lists them and answered SELECT count(*) FROM google_ml.supported_vertex_models with 15 rows.", 'The slow-query panel is always empty and health says why: pg_stat_statements ships with the image but is not installed in it, reported as "pg_stat_statements extension not enabled".', "The columnar engine is off by default and turning it on needs ALTER SYSTEM plus a restart, not a reload. With it on, the on-disk sizes stay exact but do not count the columnar copy.", - "The agent needs a least-privilege agent role to ground a run: the image's own postgres superuser is refused because the execution profile reads that role as too broad. With that role the capture succeeds (B52).", + "The agent needs a least-privilege agent role to ground a run: the image's own postgres superuser is refused because the execution profile reads that role as too broad. With that role the capture succeeds, over an inventory of 69 objects of which 2 are the user's.", "Probed on the 17.9.0 image only; the 15.x and 16.x lines were not probed.", ], }, diff --git a/tests/unit/lib/agent/context-snapshot.test.ts b/tests/unit/lib/agent/context-snapshot.test.ts index f9ad1028..e740e824 100644 --- a/tests/unit/lib/agent/context-snapshot.test.ts +++ b/tests/unit/lib/agent/context-snapshot.test.ts @@ -340,7 +340,9 @@ describe("captureContextSnapshot — wide PostgreSQL catalogs (B52)", () => { }); test("a malformed columns value yields an empty column list, never a lost snapshot", async () => { - for (const malformed of [null, "", "not json", 42, { name: "id" }]) { + // `'"nope"'` is the one that parses and is still not an inventory: valid JSON, + // wrong shape, which is a different arm from text that does not parse at all. + for (const malformed of [null, "", "not json", '"nope"', 42, { name: "id" }]) { const h = harness("postgres", async (sql: string) => sql.includes("information_schema.columns") ? result([{ table_schema: "public", table_name: "orders", columns: malformed }]) @@ -356,6 +358,52 @@ describe("captureContextSnapshot — wide PostgreSQL catalogs (B52)", () => { } }); + /** + * The element-level half of the same guard, and the one that bites: an array PASSES + * `Array.isArray`, so a non-object element reaches the fold and `column.name` is read + * off it. On `null` that is a TypeError, and `plan.build` runs OUTSIDE the catch in + * `readInventory`, so it would not degrade the capture — it would end the run + * `internal`, which is the shape B48 exists to keep out of this path. + * + * A bad element is dropped rather than emptying the list, because the rest of the + * array is still a column inventory: losing one entry says less than losing the table. + */ + test("drops column entries that are not objects, and keeps the ones that are", async () => { + const h = harness("postgres", async (sql: string) => + sql.includes("information_schema.columns") + ? result([ + { + table_schema: "public", + table_name: "orders", + columns: [null, "id", 42, { name: "customer_id", type: "integer", nullable: "NO" }], + }, + ]) + : result([]), + ); + + const capture = await captureContextSnapshot(h.context); + + expect(capture.kind).toBe("captured"); + if (capture.kind !== "captured") throw new Error("unreachable"); + expect(capture.snapshot.tables.find((table) => table.name === "public.orders")?.columns).toEqual([ + { name: "customer_id", type: "integer", nullable: false, isPrimary: false }, + ]); + }); + + test("a JSON string of non-object entries yields an empty column list", async () => { + const h = harness("postgres", async (sql: string) => + sql.includes("information_schema.columns") + ? result([{ table_schema: "public", table_name: "orders", columns: "[null, 1]" }]) + : result([]), + ); + + const capture = await captureContextSnapshot(h.context); + + expect(capture.kind).toBe("captured"); + if (capture.kind !== "captured") throw new Error("unreachable"); + expect(capture.snapshot.tables.find((table) => table.name === "public.orders")?.columns).toEqual([]); + }); + test("preserves column order for a wide table, and the aggregated payload stays inside the byte budget", async () => { const width = 2_000; const rows = [