You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
ProviderCapabilities declares an engine's container levels but not which container paths the engine accepts, so that rule lives in two places that disagree. After #1092 the provider side reads it from a constant private to each provider file (shapes: "exact" | "prefixes" on its ContainerPathShapeEngine), and the HTTP route applies a different rule of its own. This issue moves the rule into the declaration, next to containerLevels, and makes the route and the provider enforce it through one kernel reader.
The decision was taken while reviewing #1092 (merged as ef1748e). The measurements and the options we rejected are recorded below so nobody has to repeat them.
The problem, measured on the tree #1092 merged as ef1748e
Two rules for one fact. The provider accepts either exact, only the declared depth, or prefixes, every depth from one level up to the declared one, depending on the engine. The route guard assertContainerDepth in src/lib/api/object-route.ts accepts any path no longer than the declared depth, on every engine. So on an exact engine a path that is too short passes the route and is refused later by the provider, while a path that is too long is refused by the route. The two refusals come from different layers, with different wording and a different wire shape.
Measured with curl against a production build, with all fifteen engines from database-compose.yml and their fixtures, on 2026-09-27:
Request
Refused by
Status and body
POST /api/db/objects/list, PostgreSQL, container: []
provider
400 {"error":"A PostgreSQL container path is [schema], received []","code":"QUERY_ERROR","statusCode":400}
POST /api/db/objects/list, PostgreSQL, container: ["app","x"]
route
400 {"error":"postgres declares a container depth of 1, and \"container\" has 2 segments: [\"app\",\"x\"]"}
POST /api/db/objects/counts, Trino, container: []
provider
400 {"error":"A Trino container path is [catalog] or [catalog, schema], received []","code":"QUERY_ERROR","statusCode":400}
POST /api/db/objects/counts, Trino, container: ["memory"]
nothing
200, because a catalog alone is a real address on Trino
POST /api/db/objects/counts, Trino, container: ["memory","app","x"]
route
400 {"error":"trino declares a container depth of 2, and \"container\" has 3 segments: [\"memory\",\"app\",\"x\"]"}
POST /api/db/objects/inventory, PostgreSQL, containers: [[]]
provider
400, the PostgreSQL sentence above with QUERY_ERROR
POST /api/db/objects/containers, PostgreSQL, parent: ["app"]
nothing
200 [], because a parent at the declared depth truly has no children
The same class of caller mistake answers with code and statusCode when the path is too short, and with neither when it is too long. A browser sweep on the same build refused container: [] the provider's way on all twelve engines that declare a container level. The three that declare none (SQLite, libSQL, LibreDB) are refused by the route for any non-empty path, before the provider is reached.
The rule is invisible outside the provider file. The route, src/lib/db/container-walk.ts, src/lib/mcp/tools/inspect-schema.ts, src/lib/agent/tools.ts, the object tree (src/components/object-tree/use-tree-nodes.ts, flatten.ts) and the sidebar all read containerDepth() from the declaration. The UI gets that declaration as data from POST /api/db/provider-meta (src/hooks/use-provider-metadata.ts) and never holds a provider instance. None of these readers can tell whether a shorter path is an address, because that fact sits in a constant inside each provider file.
One descriptor field is already redundant. On all fifteen descriptors emptyShapes follows from shapes: every exact engine prints empty, and every prefixes engine prints nothing: this declaration carries no container level.
The provider check has to stay. Not every caller goes through the route. src/lib/mcp/tools/inspect-schema.ts calls provider.listObjects() directly, and the embedded workspace (src/workspace/hooks/use-connection-adapter.ts) hands object reads to the host's onObjectsFetch callbacks. For those callers the provider refusal is what stops undefined from being bound where a segment belongs, which would answer an empty folder that looks like a container holding nothing. The route check is the HTTP edge in front of it. Both must read the same rule.
Decision
The accepted path shapes become part of the declaration, as an optional containerPathShapes?: "exact" | "prefixes" on ProviderCapabilities. One kernel function in src/lib/db/object-kinds.ts reads it, and both assertContainerPathShape and the route use that function.
Why this option:
docs/ARCHITECTURE.md already states the principle: "All behavior differences are driven through capabilities and labels." containerLevels is the precedent in the same type: each provider declares it, and it is read only through containerDepth().
It gives behavioural isolation by construction. Changing which paths Trino accepts becomes a change to Trino's getCapabilities() and nothing else. No shared code changes, and no other engine's behaviour or tests can move.
Every reader sees one rule. The route, the provider, MCP and the tree read the same declared value, so the two layers cannot disagree again.
It shrinks the descriptor rather than growing it: shapes leaves it, and emptyShapes becomes derived.
Options considered and rejected
Keep the rule in each provider's descriptor constant, as refactor(db): one renderer for the container-path sentence #1092 shipped it. This is import-isolated and correct inside the provider, but the route, the tree and MCP cannot see the rule, so the disagreement in the table above stays.
A template method on BaseDatabaseProvider, overridden by each provider.
None of the fifteen call sites is a class method. All are free functions that take capabilities, and seven of them live in modules that contain no class at all: the objects.ts files of couchbase, cassandra, clickhouse, druid, duckdb, libsql and trino. Each call site would have to become a method or take the provider instance.
The UI reads the declaration as JSON and never holds an instance, so the rule would still be invisible to it.
The base class would become the shared kernel, with the same blast radius as a shared function, only less visible.
Making the field required. Every external implementer of the published type (src/exports/types.ts) would stop compiling, which is the same reason containerLevels is optional.
Reading an absent field as prefixes. That fails open: a provider that forgets the field would let partial paths reach reads that bind segments by position. An absent field reads as exact, the conservative answer, because security comes first.
What to change
1. The declaration (src/lib/db/types.ts). Add containerPathShapes?: "exact" | "prefixes" to ProviderCapabilities, directly after containerLevels. Its docblock states:
exact accepts only the declared depth.
prefixes accepts every depth from one level up to the declared one.
Both refuse a longer path.
An absent field reads as exact.
The field is optional for the same published-interface reason as containerLevels.
Callers read it through the kernel reader, never directly.
2. The kernel (src/lib/db/object-kinds.ts).
Add an exported reader that returns the accepted shapes as lists of declared levels, derived from declaredLevels() and the declared policy. exact with no level accepts only [], and prefixes with no level accepts nothing. Both are what refactor(db): one renderer for the container-path sentence #1092 does today.
assertContainerPathShape reads the policy from capabilities through that reader.
Remove shapes and emptyShapes from ContainerPathShapeEngine. The empty wording is derived from the policy, with both strings above unchanged. code, label and shapeNames stay.
Export the shape-list renderer, so the route spells shapes with the same code the provider uses.
Extend the ContainerPathShapeEngine docblock with the kernel rule from step 5.
3. The fifteen providers. Each provider that calls assertContainerPathShape declares containerPathShapes explicitly in getCapabilities(), with its value from the table above, and its descriptor constant loses shapes and emptyShapes. Every provider refusal sentence stays byte-identical.
4. The route (src/lib/api/object-route.ts). assertContainerDepth checks two different things today, and the two split:
parent on containers is a tree cursor: on every engine, any depth up to and including the declared one stays valid. Keep this check and its message unchanged.
container on counts and list, and every entry of containers on inventory, is an address. Check it against the kernel reader before the provider is called, so a path the engine does not accept is refused at the edge, whether it is too short or too long.
That refusal joins the route's own family: status 400 and a body of { "error": ... } with no code, like the route's other ObjectRouteError 400 refusals.
The message reads <type> accepts "<field>" as <shapes>, received <path>, with the shapes spelled from the lowercased level labels by the kernel renderer. For example: postgres accepts "container" as [schema], received [] and trino accepts "container" as [catalog] or [catalog, schema], received ["memory","app","x"].
5. The written rule (docs/ARCHITECTURE.md, next to the capabilities-and-labels line). State it in these terms:
src/lib/db/object-kinds.ts is the kernel through which every provider reads its declaration, and it takes only facts that follow from the declaration and hold for every engine.
A rule that only one engine's reads need stays in that engine's file, next to the reads it protects. The example is refactor(db): one renderer for the container-path sentence #1092's PostgreSQL containerSchema, which refuses a declaration with no schema level.
A descriptor field that only one engine sets is a sign that the rule belongs in that engine.
6. Docs, per the provider triad.
Each of the fifteen docs/providers/<type-id>.md files gets a containerPathShapes row in its capabilities table, beside containerLevels. Where a doc already explains why a partial path is a real address (for example mssql.md and trino.md), that prose names the field.
Question 7 in docs/ADDING_A_PROVIDER.md asks a new provider to choose between exact and prefixes and to declare the choice.
The object-surface section of docs/API_DOCS.md documents the new route refusal for container and containers, and states that parent keeps the depth ceiling.
Tests
Write each failing test first.
In tests/unit/db/object-kinds.test.ts: the reader for exact, prefixes and an absent field, at depths 0, 1 and 2, including an absent field reading as exact and prefixes with no level accepting nothing.
A table-driven unit test that builds each of the fifteen providers without connecting, the way POST /api/db/provider-meta does, and asserts that getCapabilities().containerPathShapes equals the table above, with the type-ids pinned by name.
Route tests for counts, list and inventory:
On an exact engine, a path that is too short gets the route sentence, with status 400 and no code, and the provider method is never called.
On a prefixes engine, an outer-level path reaches the provider.
A path that is too long gets the same sentence as one that is too short.
containers with parent at the full depth still passes.
The fifteen provider integration suites and tests/unit/db/container-path-renderer.test.ts stay green, with no assertion changed.
Non-vacuity. Apply each mutation, show that it goes red, then restore it:
The route falls back to the depth ceiling for container: the new route tests fail.
An absent field reads as prefixes: the unit test for the absent case fails.
Acceptance criteria
ProviderCapabilities.containerPathShapes exists, is optional, and is documented as described above.
The rule is read in one place: neither a provider nor a route decides the accepted depths on its own, and ContainerPathShapeEngine no longer has shapes or emptyShapes.
All fifteen providers declare the value explicitly, and a test pins each value by type-id.
Over HTTP, a container or containers path the engine does not accept is refused by the route with one sentence and one wire shape, whether it is too short or too long. parent behaves as it does today.
Every provider refusal sentence is unchanged for callers that bypass the route.
docs/ARCHITECTURE.md carries the kernel rule, and the fifteen provider docs, docs/ADDING_A_PROVIDER.md and docs/API_DOCS.md are updated in the same PR.
Each of the three mutations above goes red.
bun run format, lint, typecheck, knip, test, coverage:check, build, build:lib and attw all pass, since the type is part of the package surface.
Out of scope
Unifying shapeNames (Trino and PostgreSQL spell shapes by id). Trino has a test that varies the declaration to pin that choice, and changing it is a separate question.
The wrong-depth wordings that do not go through the shared renderer (prometheus/objects.ts, search/index.ts and kafka/objects.ts), and the provider-local copies of declaredLevels() that sit beside the exported one.
UI changes. The tree never sends a partial container path, because it walks with parent, so the UI has no reader to add yet.
Adding code to the route's refusal family as a whole.
Before merging, the maintainer will re-run the fifteen-engine live sweep behind the table above against the PR.
ProviderCapabilitiesdeclares an engine's container levels but not which container paths the engine accepts, so that rule lives in two places that disagree. After #1092 the provider side reads it from a constant private to each provider file (shapes: "exact" | "prefixes"on itsContainerPathShapeEngine), and the HTTP route applies a different rule of its own. This issue moves the rule into the declaration, next tocontainerLevels, and makes the route and the provider enforce it through one kernel reader.The decision was taken while reviewing #1092 (merged as ef1748e). The measurements and the options we rejected are recorded below so nobody has to repeat them.
The problem, measured on the tree #1092 merged as ef1748e
Two rules for one fact. The provider accepts either
exact, only the declared depth, orprefixes, every depth from one level up to the declared one, depending on the engine. The route guardassertContainerDepthinsrc/lib/api/object-route.tsaccepts any path no longer than the declared depth, on every engine. So on anexactengine a path that is too short passes the route and is refused later by the provider, while a path that is too long is refused by the route. The two refusals come from different layers, with different wording and a different wire shape.Measured with curl against a production build, with all fifteen engines from
database-compose.ymland their fixtures, on 2026-09-27:POST /api/db/objects/list, PostgreSQL,container: []{"error":"A PostgreSQL container path is [schema], received []","code":"QUERY_ERROR","statusCode":400}POST /api/db/objects/list, PostgreSQL,container: ["app","x"]{"error":"postgres declares a container depth of 1, and \"container\" has 2 segments: [\"app\",\"x\"]"}POST /api/db/objects/counts, Trino,container: []{"error":"A Trino container path is [catalog] or [catalog, schema], received []","code":"QUERY_ERROR","statusCode":400}POST /api/db/objects/counts, Trino,container: ["memory"]POST /api/db/objects/counts, Trino,container: ["memory","app","x"]{"error":"trino declares a container depth of 2, and \"container\" has 3 segments: [\"memory\",\"app\",\"x\"]"}POST /api/db/objects/inventory, PostgreSQL,containers: [[]]QUERY_ERRORPOST /api/db/objects/containers, PostgreSQL,parent: ["app"][], because a parent at the declared depth truly has no childrenThe same class of caller mistake answers with
codeandstatusCodewhen the path is too short, and with neither when it is too long. A browser sweep on the same build refusedcontainer: []the provider's way on all twelve engines that declare a container level. The three that declare none (SQLite, libSQL, LibreDB) are refused by the route for any non-empty path, before the provider is reached.The rule is invisible outside the provider file. The route,
src/lib/db/container-walk.ts,src/lib/mcp/tools/inspect-schema.ts,src/lib/agent/tools.ts, the object tree (src/components/object-tree/use-tree-nodes.ts,flatten.ts) and the sidebar all readcontainerDepth()from the declaration. The UI gets that declaration as data fromPOST /api/db/provider-meta(src/hooks/use-provider-metadata.ts) and never holds a provider instance. None of these readers can tell whether a shorter path is an address, because that fact sits in a constant inside each provider file.One descriptor field is already redundant. On all fifteen descriptors
emptyShapesfollows fromshapes: everyexactengine printsempty, and everyprefixesengine printsnothing: this declaration carries no container level.shapesexactprefixesThe provider check has to stay. Not every caller goes through the route.
src/lib/mcp/tools/inspect-schema.tscallsprovider.listObjects()directly, and the embedded workspace (src/workspace/hooks/use-connection-adapter.ts) hands object reads to the host'sonObjectsFetchcallbacks. For those callers the provider refusal is what stopsundefinedfrom being bound where a segment belongs, which would answer an empty folder that looks like a container holding nothing. The route check is the HTTP edge in front of it. Both must read the same rule.Decision
The accepted path shapes become part of the declaration, as an optional
containerPathShapes?: "exact" | "prefixes"onProviderCapabilities. One kernel function insrc/lib/db/object-kinds.tsreads it, and bothassertContainerPathShapeand the route use that function.Why this option:
docs/ARCHITECTURE.mdalready states the principle: "All behavior differences are driven through capabilities and labels."containerLevelsis the precedent in the same type: each provider declares it, and it is read only throughcontainerDepth().getCapabilities()and nothing else. No shared code changes, and no other engine's behaviour or tests can move.shapesleaves it, andemptyShapesbecomes derived.Options considered and rejected
BaseDatabaseProvider, overridden by each provider.capabilities, and seven of them live in modules that contain no class at all: theobjects.tsfiles of couchbase, cassandra, clickhouse, druid, duckdb, libsql and trino. Each call site would have to become a method or take the provider instance.A SQL Server container path is , received [...], which is why [BUG] One shared renderer for the container-path sentence, not fifteen producers #1065 was filed.src/exports/types.ts) would stop compiling, which is the same reasoncontainerLevelsis optional.prefixes. That fails open: a provider that forgets the field would let partial paths reach reads that bind segments by position. An absent field reads asexact, the conservative answer, because security comes first.What to change
1. The declaration (
src/lib/db/types.ts). AddcontainerPathShapes?: "exact" | "prefixes"toProviderCapabilities, directly aftercontainerLevels. Its docblock states:exactaccepts only the declared depth.prefixesaccepts every depth from one level up to the declared one.exact.containerLevels.2. The kernel (
src/lib/db/object-kinds.ts).declaredLevels()and the declared policy.exactwith no level accepts only[], andprefixeswith no level accepts nothing. Both are what refactor(db): one renderer for the container-path sentence #1092 does today.assertContainerPathShapereads the policy fromcapabilitiesthrough that reader.shapesandemptyShapesfromContainerPathShapeEngine. The empty wording is derived from the policy, with both strings above unchanged.code,labelandshapeNamesstay.ContainerPathShapeEnginedocblock with the kernel rule from step 5.3. The fifteen providers. Each provider that calls
assertContainerPathShapedeclarescontainerPathShapesexplicitly ingetCapabilities(), with its value from the table above, and its descriptor constant losesshapesandemptyShapes. Every provider refusal sentence stays byte-identical.4. The route (
src/lib/api/object-route.ts).assertContainerDepthchecks two different things today, and the two split:parentoncontainersis a tree cursor: on every engine, any depth up to and including the declared one stays valid. Keep this check and its message unchanged.containeroncountsandlist, and every entry ofcontainersoninventory, is an address. Check it against the kernel reader before the provider is called, so a path the engine does not accept is refused at the edge, whether it is too short or too long.{ "error": ... }with nocode, like the route's otherObjectRouteError400 refusals.<type> accepts "<field>" as <shapes>, received <path>, with the shapes spelled from the lowercased level labels by the kernel renderer. For example:postgres accepts "container" as [schema], received []andtrino accepts "container" as [catalog] or [catalog, schema], received ["memory","app","x"].5. The written rule (
docs/ARCHITECTURE.md, next to the capabilities-and-labels line). State it in these terms:src/lib/db/object-kinds.tsis the kernel through which every provider reads its declaration, and it takes only facts that follow from the declaration and hold for every engine.containerSchema, which refuses a declaration with noschemalevel.6. Docs, per the provider triad.
docs/providers/<type-id>.mdfiles gets acontainerPathShapesrow in its capabilities table, besidecontainerLevels. Where a doc already explains why a partial path is a real address (for examplemssql.mdandtrino.md), that prose names the field.docs/ADDING_A_PROVIDER.mdasks a new provider to choose betweenexactandprefixesand to declare the choice.docs/API_DOCS.mddocuments the new route refusal forcontainerandcontainers, and states thatparentkeeps the depth ceiling.Tests
Write each failing test first.
tests/unit/db/object-kinds.test.ts: the reader forexact,prefixesand an absent field, at depths 0, 1 and 2, including an absent field reading asexactandprefixeswith no level accepting nothing.POST /api/db/provider-metadoes, and asserts thatgetCapabilities().containerPathShapesequals the table above, with the type-ids pinned by name.counts,listandinventory:exactengine, a path that is too short gets the route sentence, with status 400 and nocode, and the provider method is never called.prefixesengine, an outer-level path reaches the provider.containerswithparentat the full depth still passes.tests/unit/db/container-path-renderer.test.tsstay green, with no assertion changed.exact: the provider suites' prefix tests fail. On refactor(db): one renderer for the container-path sentence #1092, collapsingprefixestoexactfailed 37 tests across Trino and SQL Server.container: the new route tests fail.prefixes: the unit test for the absent case fails.Acceptance criteria
ProviderCapabilities.containerPathShapesexists, is optional, and is documented as described above.ContainerPathShapeEngineno longer hasshapesoremptyShapes.containerorcontainerspath the engine does not accept is refused by the route with one sentence and one wire shape, whether it is too short or too long.parentbehaves as it does today.docs/ARCHITECTURE.mdcarries the kernel rule, and the fifteen provider docs,docs/ADDING_A_PROVIDER.mdanddocs/API_DOCS.mdare updated in the same PR.bun run format,lint,typecheck,knip,test,coverage:check,build,build:libandattwall pass, since the type is part of the package surface.Out of scope
shapeNames(Trino and PostgreSQL spell shapes byid). Trino has a test that varies the declaration to pin that choice, and changing it is a separate question.prometheus/objects.ts,search/index.tsandkafka/objects.ts), and the provider-local copies ofdeclaredLevels()that sit beside the exported one.parent, so the UI has no reader to add yet.codeto the route's refusal family as a whole.Before merging, the maintainer will re-run the fifteen-engine live sweep behind the table above against the PR.