Skip to content

fix(driver-sql)!: PG introspectSchema reads each table's columns in its own schema and refuses a same-named pair across schemas - #24920

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24707-pg-multischema-introspect
Oct 20, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24707-pg-multischema-introspect

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #24707

Clause-②: no (narrowing)

Dev session session_013timUNNCthRfQkhy9Hz2fV, dispatched by the domain:engine seat 1 (session_01JtSQQ3EWsbNPzyYMug1tXy) under claim 6292170810. The review of record is owed at CONTRACT_REVIEW_TIER, on the narrowing face.

What changes

SqlDriver.introspectSchema, PostgreSQL branch only (packages/drivers/driver-sql/src/sql-driver.ts):

  • Columns read in the table's own schema. The table listing now also selects table_schema (and its position in current_schemas(false)). Each table's columns are read with knex withSchema(schema).columnInfo(), and the declared-order read introspectColumnOrder uses table_schema = ? for that schema. Before, both read by bare name, which knex scopes to current_schema(), the first schema on the path only. introspectColumns / introspectColumnOrder gain an optional trailing schema parameter. Both are protected, so no new public member. Every other caller passes none, and its SQL is byte-identical to before.
  • A same-named pair is refused, never dropped. When the session's search_path reaches one bare table name in more than one schema, introspectSchema throws RESOURCE_CONFLICT / status: 409, naming each colliding table with its schemas in search_path order. The message gives the remedy: one schema per connection (the datasource's schema setting sets its search_path), or a rename.
  • Unchanged: a single-schema connection (the default public deployment), and the SQLite and MySQL branches. The listing query gains no ORDER BY, so a single-schema answer keeps its key order.

Not touched, per the claim: no schema member on IntrospectedTable, no packages/spec change, no edit to service-datasource (read only, as the consumer).

Measured before and after (throwaway PostgreSQL 16.15 cluster, local postgres binary)

The card's database: public.customers, sales.orders, sales.customers, and ALTER DATABASE … SET search_path = public, sales. It was read through SqlDriver.introspectSchema and through ExternalDatasourceService.listRemoteTables, the service class constructed with the driver as its introspect, with no server.

database before (fcb40ad673) after (1eeab8089)
card (public, sales, customers in both) tables customers (public's 3 columns), orders with columns: [] and primaryKeys: ['id']; sales.customers absent; listRemoteTables → customers columnCount 3, orders columnCount 0 RESOURCE_CONFLICT / 409: Colliding: 'customers' ('public', 'sales'), from both introspectSchema and listRemoteTables
card without sales.customers (same zero-column orders) orders reads its 4 columns (id, customer_id, total, placed_at); listRemoteTables → columnCount 3 and 4
single schema (public only, customers + orders with an FK) sha256 of the JSON answer (with introspectedAt masked) a273d463…f8 the same a273d463…f8: byte-identical

The collision answer: refusal (four axes)

Callers of introspectSchema: ExternalDatasourceService (listRemoteTables, generateObjectDraft, refreshCatalog, validateObject*, and testConnection for a non-primary datasource), through ObjectQL.introspectDatasource or the driver directly (service-datasource/src/plugin.ts). Also the datasource admin plugin's connection test, only as a last fallback after ping / checkHealth, which SqlDriver has. driver-turso overrides it (SQLite). No migration differ or os command calls it (packages/cli/src/utils/unmanaged-tables.ts mirrors its listing statement and does not call it). Every caller already surfaces a throw: the listing, draft and catalog doors return the error, and testConnection answers { ok: false, error }.

  • Business need: the only connection that can collide is an external PostgreSQL datasource whose search_path names two or more schemas holding a same-named table, which is the finder's measured shape. A single-schema connection can never collide, so no internal caller that sees one schema is refused.
  • Long-term soundness: "keep the first, name the other" has no channel to name it in without a spec change (IntrospectedSchema carries no warnings, and a schema member on IntrospectedTable is the spec decision the triage withheld). That leaves a server log line, a workaround in all but name.
  • Making AI mistakes hard: a log line is invisible to the HTTP / AI caller, so for them "keep the first" is the same silent drop this card exists to end. A loud refusal with a named remedy cannot be mistaken for a complete listing.
  • Startup focus: no new surface, no new option, no spec key, and no new gate.

Rejected third shape, recorded: keying the shadowed twin as a qualified sales.customers. The consumers' parseQualified would read it, but a key whose spelling depends on context is exactly what this file's IntrospectedForeignKey.referencedTable note rejects (#11377), and it is outside the two options the claim names.

Pins

New packages/drivers/driver-sql/src/sql-driver-24707-introspect-pg-multischema.test.ts, a live-PG cell declared through declareDialectCell (a named skip without OS_TEST_POSTGRES_URL). In CI it runs in Temporal Conformance (live PG + MySQL)'s driver-sql step (ci.yml, "Run driver-sql suite against both live servers"), which is in main's full battery. In PR runs the file is a named skip.

  1. non-vacuity: the session's path is both schemas, and the bare-name columnInfo() reads zero columns for the later schema's table;
  2. the later schema's table reads its own columns (exact array) and its key;
  3. a same-named pair is refused: { code: 'RESOURCE_CONFLICT', status: 409 }, and the message names the table and both schemas;
  4. control: a single-schema session lists only its own table, and the schema-scoped column read equals the bare-name read.

Ablation (committed fix, node scripts/ablation-replace.mjs, one leg per mutation, each restored blob-equal to HEAD 74cd151dd2be with git diff HEAD empty; the test imports ../src, so no dist leg):

  • leg A, the column read reverted to the bare name: pin 2 red (1 failed / 3 passed);
  • leg B, the refusal deleted: pin 3 red (expected undefined to be defined), 1 failed / 3 passed.

Pin sweep (introspectSchema, introspectColumns, columnInfo, current_schemas, columnCount across packages/, qa/, docs/qa/platform-checklist/, content/docs/): no existing pin moves. unmanaged-tables.test.ts asserts current_schemas(false) in the CLI's own mirror statement, which is unchanged. The checklist's external-datasource items run on a SQLite fixture, and their knownGaps text about bare names from current_schemas(false) stays true. No docs/qa/platform-checklist/** edit.

Verification record (head 1eeab8089, after merging origin/main a8b44d407)

  • pnpm --filter @objectstack/driver-sql typecheck: exit 0 (its tsconfig includes src/**/*, so the new test is type-checked).
  • pnpm --filter @objectstack/driver-sql test: exit 0, 224 files passed / 12 skipped, 3687 tests passed / 209 skipped (no live URL, as in Test Core). It ran on b61cf5026; the merge brought only platform-objects files.
  • The introspection live files (21 files) with OS_TEST_POSTGRES_URL on the local cluster: exit 0, 170 passed / 10 skipped (MySQL cells, no MySQL URL).
  • The new pin file on the merged head, live PG: exit 0, 4 passed.
  • pnpm --filter @objectstack/service-datasource test (the consumer), after building its dependency closure: exit 0, 48 files / 862 tests passed.
  • pnpm exec turbo run build --concurrency=2 on the merged head: exit 0, 77/77 tasks.
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 70 families, each run with its exit code captured before any pipe, all 0. check:dual-build-cjs-loads first answered 3 (PREREQUISITE NOT MET, no dist) and was re-run after the full build: exit 0. check:type-check-debt first hit my per-command 280 s cap (124) while the box was busy and was re-run: exit 0. --ran reconciliation: 70 derived, 70 run, 0 NOT-MEASURED, all 70 carrying an exit code.
  • pnpm --filter @objectstack/core test:repo: exit 0, 6 files / 82 tests passed.

Acceptance notes

  • The three key reads introspectSchema makes after the columns (introspectPrimaryKeys by ?::regclass, introspectForeignKeys and introspectUniqueConstraints scoped to current_schemas(false)) are unchanged. With base-table collisions refused, each now answers for the one base table of that name on the path. One residual shape is a read-only inference, not measured: a VIEW (or other relation) named like a later schema's table but sitting in an earlier schema would still capture the regclass resolution of the primary-key read. Noted, not filed.
  • The CLI's unmanaged-tables sweep lists names across the same schemas and is untouched.

Generated by Claude Code

@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/driver-sql, touching 5 documentable anchor(s).

9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/data-modeling/index.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/data-modeling/queries.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/permissions/tenant-audit-census.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/lifecycle.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/objectql/query-syntax.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/objectql/types.mdx (via SqlDriver (symbol, a top-level class))

⛔ 4 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/releases/v17/17-2.mdx (via introspectSchema (symbol, a method of class SqlDriver))
  • content/docs/releases/v17/17-5.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/releases/v17/17-6.mdx (via SqlDriver (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json a7199ef9744edf0c3ef0e381bb95a328545a3da9 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 52d278f73411c764dacc0708876b42c422b786cd — the merge of head 1eeab8089f223dd08909f385a79ff4c1b0e8f992 into base a7199ef9744edf0c3ef0e381bb95a328545a3da9, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 52d278f73411c764dacc0708876b42c422b786cd && git checkout 52d278f73411c764dacc0708876b42c422b786cd
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a7199ef9744edf0c3ef0e381bb95a328545a3da9 1eeab8089f223dd08909f385a79ff4c1b0e8f992 && git checkout -B drift-repro a7199ef9744edf0c3ef0e381bb95a328545a3da9 && git merge --no-ff 1eeab8089f223dd08909f385a79ff4c1b0e8f992

node scripts/docs-audit/affected-docs.mjs --json a7199ef9744edf0c3ef0e381bb95a328545a3da9

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs a7199ef9744edf0c3ef0e381bb95a328545a3da9 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 1eeab8089f223dd08909f385a79ff4c1b0e8f992
Local-runs: none
Scope: packages/drivers/driver-sql/src/sql-driver.ts, packages/drivers/driver-sql/src/sql-driver-24707-introspect-pg-multischema.test.ts, .changeset/24707-pg-multischema-introspect.md + packages/services/service-datasource/src/external-datasource-service.ts, packages/services/service-datasource/src/admin-routes.ts, packages/services/service-datasource/src/datasource-admin-plugin.ts, packages/services/service-datasource/src/datasource-admin-service.ts, packages/services/service-datasource/src/default-datasource-driver-factory.ts, packages/services/service-datasource/src/plugin.ts, packages/rest/src/external-datasource-routes.ts, packages/objectql/src/engine.ts, packages/runtime/src/external-validation-plugin.ts, packages/drivers/driver-turso/src/turso-driver.ts, packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver.ts, packages/drivers/driver-sql/src/live-dialect-matrix.testkit.ts, packages/drivers/driver-sql/src/sql-driver-11201-introspect-fk-schema-scope.test.ts, packages/drivers/driver-sql/tsconfig.json, packages/cli/src/utils/unmanaged-tables.ts, packages/spec/src/data/driver/postgres.zod.ts, packages/spec/src/api/errors.zod.ts, .github/workflows/ci.yml, scripts/check-changeset-no-major.mjs, scripts/pm/clause2-line.mjs, scripts/check-adr-0087-registration.mjs, knex 3.3.0 lib/dialects/postgres/query/pg-querycompiler.js (upstream, at the version pnpm-lock.yaml pins)

Inputs: card #24707 body and all four comments (triage 6282275557, claim 6292170810, dispatch 6292244225, os-dev-report 6293456848 read as claims); #11201 body and comments; PR #24920 body, file list (3 files, +271/−11) and git diff a8b44d4070..1eeab8089f (merge base a8b44d4070); the check-runs on the head. Head repo equals base repo (not a fork). Check-runs at first read 2026-10-20T15:02Z: Test Core (2/6) and (4/6) in progress; re-read 2026-10-20T15:16Z: all 41 runs completed, every conclusion success or skipped, none running. Skipped by design on a pull_request event: Temporal Conformance (live PG + MySQL), Console Pin Gate, Build Docs, the Dogfood shards, Dogfood Verify CLI, 18.x upgrade harness, Packed-tarball smoke. Green: Check Changeset, Governed Surface Queue Guard, Lint · CI release & engine gates, Lint · ESLint, Type Check · workspace, Type Check · consumer gates, Test Core 1-6, Build Core, the three claim/branch guards, PR Size.

① Derived judgments

The refusal's reach — right. The only new refusal is in the PostgreSQL arm of SqlDriver.introspectSchema (sql-driver.ts head 15497-15536): the listing now selects table_schema and array_position(current_schemas(false), table_schema::name), groups rows by bare table_name, and when any name has two or more rows it throws postgresTableNameCollisionError (module-private, not exported; head 4879-4895) with code = StandardErrorCode.enum.RESOURCE_CONFLICT, status = 409, BEFORE any column or key read. MySQL (DATABASE()) and SQLite arms are untouched, so no other dialect can meet it; schemaByTable stays empty there and introspectColumns receives undefined.

Who reaches introspectSchema in the tree at this head (git grep, every non-test caller): ObjectQL.introspectDatasource (engine.ts 21513-21522, pure delegation, throws pass through); service-datasource/plugin.ts:94-99 wiring config.introspect; ExternalDatasourceService.listRemoteTables (external-datasource-service.ts 511-519 — the primary datasource is refused by primaryDatasourceNotFederatedError before any introspection), testConnection (582-603), generateObjectDraft (614), refreshCatalog (763), validateObject / validateAll / validateEach via sweepScopedIntrospect (814, 952, 997-1025); the datasource admin plugin's probe (datasource-admin-plugin.ts 871-873, ping → checkHealth → introspectSchema as last fallback — SqlDriver has checkHealth at head 7313, so the fallback never reaches introspectSchema for this driver); TursoDriver.introspectSchema override (turso-driver.ts 4382-4394, SQLite/libSQL, isPostgres false). No migration differ, no schema sync and no os command calls it: packages/cli/src/utils/unmanaged-tables.ts:198-213 mirrors the listing statement and does not call the method (its PG mirror is unchanged); schema-drift.ts only references it in prose. The three internal introspectColumns(tableName) call sites (head 13854, 14178, 14638) pass no schema and keep their exact SQL.

Platform-owned callers: (a) the boot-time federated validation (runtime/src/external-validation-plugin.ts): a colliding federated datasource now rows EVERY object bound to it unreachable (validateEach 997-1025), and the plugin logs a warn and continues boot — no onMismatch: 'fail' abort for unreachable (326-349, the 2026-08-23 ruling). Coverage shrinks for that one datasource until the operator sets schema; before, its answer was wrong for the shadowed twin and the zero-column tables, so nothing correct is lost. (b) The platform's OWN datasource is built through the same factory (default-datasource-driver-factory.ts:1146-1153 is the only client: 'pg' construction outside tests), but listRemoteTables refuses the primary outright, testConnection on the primary takes checkHealth whenever liveDriver is wired (plugin.ts 154-162 wires it), and no boot or sync path introspects it; a service constructed without liveDriver (tests, a bespoke host) would introspect the primary and answer { ok: false, error } — the contract testConnection declares. (c) No per-tenant or per-organization schema switch exists in driver-sql: tenancy is a column (organization_id), physicalSchemaByObject is ADR-0015's per-object external.remoteSchema applied with withSchema on the builder (head 12255, 15610), not a search_path. An internal connection therefore sees two schemas only when the operator's role/database default search_path names them AND the datasource config carries no schema: datasource-admin-service.ts:1061 judges the record with DatasourceSchema.safeParse but persists the RAW record, so a saved datasource that omits schema gets no knex searchPath and inherits the server default — exactly the card's measured shape, and the only platform door that can collide. A datasource with schema set (the spec default is 'public', postgres.zod.ts:247-250) has knex issue set search_path to "one-schema" per pooled connection and can never collide.

testConnection keeps its contract on both arms: the catch at 601-603 answers { ok: false, error: message }; the admin plugin's probe answers { ok: false, error } from its own catch (datasource-admin-plugin.ts 884-885).

The detection — right. Judged on information_schema.tables rows with table_type = 'BASE TABLE' and table_schema = ANY (current_schemas(false)), grouped by the stored table_name. Base tables only: views, foreign tables and materialized views are not rows and so never collide nor get listed — the same exclusions as before. Partitioned parents and partitions report BASE TABLE and are listed and counted exactly as before. Case folding and quoted identifiers: the stored name is compared, so "Customers" and customers are two keys, each read in its own schema — correct, because knex binds the same stored string in columnInfo. A schema not on the path (or not readable by the role) is excluded by current_schemas(false) — matching bare-name resolution, so nothing the connection cannot reach unqualified is refused. current_schemas(false) excludes the implicit pg_catalog / pg_temp entries, unchanged from main. Order: schemas per collision are sorted by path_position, collisions by name — deterministic text. Residual (the dev's own out-of-scope note, agreed): a VIEW in an earlier path schema named like a later schema's base table is not a collision; the base table is listed once with its own columns (the withSchema read), but introspectPrimaryKeys' ?::regclass (head 21352) resolves the view and answers primaryKeys: []. introspectForeignKeys (child.relname = ? AND ns.nspname = ANY (current_schemas(false)), head 21163-21164) and introspectUniqueConstraints (tc.table_schema = ANY (current_schemas(false)), head 21524) merge rows of every same-named base table on the path, which the refusal now makes unambiguous among base tables; a view holds no constraints, so neither is disturbed by the shadow. Nothing the card names is missed.

The column read — right, and the single-schema answer is unchanged by construction. introspectColumns(tableName, schema?) (head 20987-20989) builds this.knex(tableName) and applies .withSchema(schema) only when a schema is passed; knex 3.3.0's pg compiler (pg-querycompiler.js 249-275, the version pnpm-lock.yaml pins; no knex is installed in this checkout, so read from the upstream source at that tag) then appends and table_schema = ? bound to the schema, else and table_schema = current_schema(). introspectColumnOrder(tableName, schema?) (head 20945-20958) mirrors it: table_schema = ? with [tableName, schema], else current_schema() with [tableName] — byte-identical to main when schema is undefined, which is every caller except introspectSchema. For a single-schema connection, schemaByTable.get(name) is that one schema, so table_schema = 'public' replaces current_schema() with the same value; columnInfo output is keyed by column name and then ordered by ORDER BY ordinal_position, so columns, their order and types cannot differ; the three key reads are untouched, so primaryKey / isUnique flags and primaryKeys / foreignKeys cannot differ; the listing query adds two SELECT columns and no ORDER BY, so tableNames keeps the same row order as before (rows.map over row.table_name). The dev's claim of an identical sha256 (a273d463…f8, introspectedAt masked) is consistent with this reading; the code gives no path by which the single-schema answer could move.

The refusal's text — right. It names the count, the first colliding name with its schemas, the mechanism (one key, bare-name resolution to the first schema), every collision as 'table' ('schema1', 'schema2') in search_path order, and two remedies: point the connection at one schema (the datasource's schema setting) or rename a table. The schema setting is real and read: declared at packages/spec/src/data/driver/postgres.zod.ts:247-250 (schema, default 'public', described "Default schema (knex searchPath)"), consumed at default-datasource-driver-factory.ts:1148-1153 as cfg.schema → searchPath on the SqlDriver config (SqlDriverConfig = Knex.Config & {...}, head 5198), which knex applies with set search_path to "schema" on every pooled connection. Setting it therefore yields a single-schema path and ends the collision. A deployment that needs tables from BOTH schemas is served by two datasources, one per schema; the text says "one schema per connection", which implies it without spelling it out — acceptable. Disclosure: the message names schema and table names the connection's own role can list; the HTTP callers are manage_platform_settings holders (admin-routes requireDatasourceAdmin) who can already list them through listRemoteTables and ?schema=, and the existing unattributedSchemaFilterError already names tables the same way. No tracker number in the runtime string (AGENTS.md refusal-prose rule): correct; the [#24707] references are in code comments only.

Precision point, not a defect of this PR: at both HTTP doors a throw from listRemoteTables / generateObjectDraft / the saved-datasource test is answered 400 EXTERNAL_DATASOURCE_ERROR carrying the message — admin-routes.ts:635-641 (badRequest, 503 passthrough only) and rest/external-datasource-routes.ts:459-470 (answerThrown: "Every other throw keeps this family's own 400 and code, a declared 4xx included"). The 409 RESOURCE_CONFLICT pair is the driver's in-process contract (what ObjectQL.introspectDatasource, the service and testConnection's error text see); the changeset's TO describes introspectSchema()'s throw and says the callers "surface that error", which they do by message. The PR's "answers the 409 through listRemoteTables" measurement was made on the service class with no server, as the body states. A CHANGELOG reader should not expect a 409 at the HTTP door; a one-line note in the changeset would remove the ambiguity. Not required for PASS: the HTTP mapping belongs to service-datasource / rest, outside this claim.

The pins — assert the new truth with teeth; both ablation legs go red by construction. sql-driver-24707-introspect-pg-multischema.test.ts, declared via declareDialectCell(PG_CELL, …) (testkit 506-540): without OS_TEST_POSTGRES_URL it is declareUnprovisionedCell → a named skip, red under OS_EXPECT_LIVE_DIALECT_MATRIX=1 (testkit 357-378). Fixture: the file's own isolated schema here (currentLiveSchema(), testkit 215-228) plus here_b (63-byte guard asserted), one driver with searchPath: [here, there] overriding the cell's single-schema config() (testkit 300-306), one control driver. Pin 1 (non-vacuity) asserts current_schemas(false) is exactly [here, there] and that the bare columnInfo() on the later schema's table answers []. Pin 2 asserts the key set, the later table's exact 4-tuple column array [name, type, primaryKey], its primaryKeys, and the first table's column names. Pin 3 creates the twin, asserts a throw is defined, { code: 'RESOURCE_CONFLICT', status: 409 } exactly, and that the message contains 'os24707_customers' ('here', 'there') — the named subjects. Pin 4 (control) asserts the single-schema key set, that introspectColumns(name, here) equals introspectColumns(name), and the exact flag triples. Leg A (bare call in introspectSchema) makes pin 2's column array [] → red, as reported; leg B (refusal line deleted) makes thrown undefined → pin 3's toBeDefined red with exactly the reported "expected undefined to be defined". Imports ../src/index.js like 115 sibling files in src/, so no dist leg; tsconfig.json includes src/**/*, so the file is type-checked (Type Check · workspace green).

What the lane means for THIS gate: ci.yml:1693-1698 runs temporal-conformance only when github.event_name is neither pull_request nor merge_group, and its step "Run driver-sql suite against both live servers" (1825-1837) is where OS_TEST_POSTGRES_URL and OS_EXPECT_LIVE_DIALECT_MATRIX=1 are set. So on this head the check-run Temporal Conformance (live PG + MySQL) is skipped, the Test Core shards ran the file as a named skip, and NO check-run on the head measured the four pins; the only measurement is the dev's local live run (4 passed; two red ablation legs), read as a claim. That is the standing shape of every live-PG pin in this package (the #11201 pin and the ~40 declareDialectCell files, ci.yml:349 "Temporal Conformance stay filtered exactly as they are today"): the live battery is main's push battery and a red there is a main-red the owning seat fixes. A pin that runs only after merge is acceptable here because it is the package's convention, not a deviation this PR introduces, and because the pins' SQL and assertions are plain enough to judge from the code — judged, and they hold.

② Semver level

minor + BREAKING for @objectstack/driver-sql — right. The accept set of introspectSchema() narrows on PostgreSQL (a connection that answered now throws), so Clause-②: no (narrowing) is the truthful arm (scripts/pm/clause2-line.mjs:95: "NOT a widening, but breaking"), and (narrowing) is BREAKING (AGENTS.md Post-Task Checklist 3). The launch-window convention ships breaking as minor (check-changeset-no-major.mjs:58-85), with the BREAKING banner and the ADR-0087 disposition as the mandatory carriers: both present. FROM (one of two tables answered), TO (RESOURCE_CONFLICT / 409, callers surface it), Fix (set schema or rename) are stated. Clause-②: no (narrowing) is at a line start in the changeset body (line 7) and in the PR body (line 3); readClause2Line (clause2-line.mjs 124, 226, 286) reads value no and arm narrowing from both. The ADR-0087 HTML-comment marker reads not-required (no-migration-prescription), a CATEGORIES member (check-adr-0087-registration.mjs 147-154, refused only when the body prescribes rewriting consumer code — the Fix line prescribes connection configuration or a remote rename, not a code rewrite). Check Changeset and Lint · CI, release & engine gates are green on the head, so the gate's own reading agrees.

The optional trailing schema parameter on two protected methods — a widened subclass-facing signature, not a new member, and not a Clause-② payload key; acceptable. driver-sql emits declarations (tsconfig.json declaration: true, package.json types: dist/index.d.ts), and protected members appear in a published .d.ts, so introspectColumns(tableName: string, schema?: string) and introspectColumnOrder(tableName: string, schema?: string) are a visible change to every SqlDriver subclass. No gate reads driver-sql's surface (packages/spec/api-surface/*.json cover spec entries only). In-tree subclasses: TursoDriver overrides introspectColumns(tableName: string) with the old arity (turso-driver.ts:4014) — assignable, compiles (Type Check · workspace green), and behaviourally right because on SQLite/libSQL introspectSchema always passes undefined; SqliteWasmDriver overrides neither; the in-package LegacyStorageDriver testkit overrides neither. An out-of-tree PostgreSQL subclass that overrides introspectColumns with the old arity would drop the schema and keep main's bare read — no regression against main. It is not a "new public member on an exported class": no member is added, both are protected, and the helper postgresTableNameCollisionError is module-private. A private helper could carry the fix with no signature change (read schemaByTable inside introspectSchema and call a private schema-scoped reader), but it would hide the schema from the one seam a subclass demonstrably overrides (TursoDriver does), so the chosen shape is the sounder one. The changeset does not mention the new parameter; the PR body does. Additive and optional, so no FROM/TO is owed for it; a sentence for subclass authors would be an improvement, not a requirement.

Nothing else widens. No new export (git diff: the only new top-level declaration is the un-exported helper), no new error code (RESOURCE_CONFLICT exists in errors.zod.ts:95 with standard status 409 at errors.zod.ts:189), no spec key, no IntrospectedTable member, no packages/spec path in the file list.

③ Boundary flags

Implemented-by: claude/issue-24707-pg-multischema-introspect
Reviewed-by: session_01JtSQQ3EWsbNPzyYMug1tXy

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-20T15:28Z · ACCEPT

Head: 1eeab8089f. It carries the PostgreSQL fix, the live-PG pins, the changeset and a merge of main.

Card #24707 (p3, bug, area:api; triage 6282275557): claim 6292170810, dispatch 6292244225. Dev report: 6293456848.

Item Reading
Shape Draft PR → main. The body opens Fixes #24707. 3 files, +271/−11. Not governed: check-governed-merges matched 0 of 3 paths; 282 changed lines.
Scope On PostgreSQL, introspectSchema reads each table's columns in its own schema, and refuses a search_path that reaches one bare table name in two schemas (RESOURCE_CONFLICT / 409, naming each table and its schemas). The single-schema answer is unchanged. SQLite and MySQL are untouched. No spec change, and no IntrospectedTable member.
Seat's own read The remedy the refusal names is real. postgres.zod.ts's schema reaches knex searchPath in default-datasource-driver-factory.ts. So only a saved external datasource that omits schema, on a server whose default search_path names two schemas, can meet the refusal. content/docs/data-modeling/external-datasources.mdx says nothing about search_path or multi-schema listing, so the docs-drift list holds no page this change makes false. git merge-tree against today's origin/main (which carries PR #24906's driver-sql hunks) is clean.
Review CONTRACT_REVIEW_TIER, narrowing face. Record 6294518392 is PASS on this head.
Declaration One changeset, @objectstack/driver-sql minor, BREAKING (FROM / TO / Fix), Clause-②: no (narrowing) at a line start, as on the claim and the PR body; adr-0087: not-required (no-migration-prescription). The new optional schema parameter on two protected methods is a subclass-visible signature change, not a new member; the record judges it the sounder shape (TursoDriver overrides introspectColumns).
Cross-lane None: driver-sql is this lane's. service-datasource, the consumer, was read only. No objection on the card or the seat posts.
Gates Dev at 1eeab8089: 70 derived, 70 run, all exit 0. driver-sql full suite 3687 passed, 21 live-PG introspection files 170 passed, service-datasource 862 passed, core test:repo 82 passed, full build 77 tasks. Ablation: two legs, each red, restored to HEAD.
CI At this read on 1eeab8089f: ci-failure GREEN, all 41 check-runs completed, none failed. The four live-PG pins are a named skip in this PR's Test Core lane and run in main's Temporal Conformance battery, the package's standing convention (record ①). That battery's first run on main after the merge is the pins' CI measurement, and the seat reads it.

Disposition:

  • open_questions: A, the 409 refusal (record ③): a log line would be the same silent drop for the HTTP and AI callers. RESOURCE_CONFLICT is an existing code.
  • deviations: all accepted (record ③).
  • out_of_scope_findings: introspectPrimaryKeys' ?::regclass can resolve a view in an earlier schema. That is an inference, not measured, so it meets no filing gate; it is noted in the PR.
  • Precision point (record ①), not a defect: the HTTP doors (admin-routes.ts, rest's external-datasource routes) answer the refusal as 400 EXTERNAL_DATASOURCE_ERROR carrying its message; the 409 pair is the driver's in-process contract. The landing comment says so.

Landing: ordinary queue (pr_ready + automerge_enable through the relay). Fixes #24707 then closes the card.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 20, 2026 15:29
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 20, 2026 15:30
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 20, 2026
Merged via the queue into main with commit 74fcec1 Oct 20, 2026
42 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24707-pg-multischema-introspect branch October 20, 2026 16:04
os-steve pushed a commit that referenced this pull request Oct 20, 2026
… into 7b-0

Conflicts in seven fixture files take #24893's resolution (4dadd7e): where
7a-2 now writes the grant with an organization the pre-18 relax goes; where
main still lands an organization-less row the relax stays.
sharing-rule-criteria-required takes main's version verbatim (both sides had
added the organization key). plugin-webhooks' new real-SqlDriver case gets the
lean engine's Default Organization.

Claude-Session: https://claude.ai/code/session_018EszgtA3V2KXBX5tf59Hi4
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants