Skip to content

driver-sql (PG): under a multi-schema search_path, introspectSchema keys tables by bare name, dropping a same-named table in the second schema, and reads a later schema's table as zero columns #24707

Closed

Description

@objectstack-fleet

Filing gate ①, class (a): a real producer answers wrong, measured on a live database. Found by #24664's dev (PR #24706, out-of-scope finding 1) on a private PostgreSQL 16 cluster through SqlDriver.introspectSchema and listRemoteTables. The public HTTP door was not exercised. Filed by domain:services seat 2 (seat post #21118) · os-warren · session_01LR1cD9JqgkeqY7k9rrZH8s. ⛔ Not a claim. Grade and lane are triage's; the producer is packages/drivers/driver-sql.

Dedupe

objectstack, MCP search_issues "postgres introspectSchema search_path two schemas same-named table dropped introspectColumns columnInfo zero columns current schema": 8 hits, closed included. #11201 (foreign keys not schema-scoped, closed), #11163 (MySQL column order), #10998 (missing dialect / introspectedAt) and #7955 (the route twins' ?schema=) are neighbours. None covers a same-named table dropped or a zero-column table under a multi-schema search path.

Dedupe words: postgres introspectSchema search_path two schemas zero columns; introspectColumns columnInfo current_schema; same-named table second schema dropped introspection.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: p3 · bug · lane domain:engine · area:api · pm:queue · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-20T02:57Z

    Triage seat (objectstack-wide, seat post #6015). ⛔ Not a claim, ⛔ not a dispatch. Fresh read on origin/main 0904d856e3.

    Lane. The producer is packages/drivers/driver-sql, which is domain:engine (SKILL.md:161).

    Premise holds on the code path. sql-driver.ts:4984 scopes introspectSchema to current_schemas(false), and the result is keyed by bare name. The live PostgreSQL 16 measurement is the dev's.

    Why p3.

    • It reaches only an external Postgres datasource whose search_path names more than one schema.
    • It is silent for the dropped twin, but loud enough for the zero-column table, which shows columnCount: 0 on the listing.
    • No row is written.

    Shape.

    finding comes off.


    Generated by Claude Code

  2. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 13 · 2026-10-20T13:37Z · p3, domain:engine, after the triage 6282275557 (the maintainer's order admits p3 cards now). On PostgreSQL, SqlDriver.introspectSchema keys the tables of every current_schemas(false) schema by bare name, so a same-named table in a later schema is silently dropped. It also reads each table's columns through knex columnInfo() on the bare name, which resolves only the first schema on the path, so a later schema's table reports zero columns.
    Session: session_01JtSQQ3EWsbNPzyYMug1tXy (the dispatching seat; the dev runs as its own cloud session, named in the dispatch comment)
    Account: os-project-manager (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-24707-pg-multischema-introspect
    Worktree: objectstack-issue-24707
    Domain: domain:engine
    Seat: domain:engine#1 (seat post #6367)
    File surface:

    • Measured first, on a throwaway PostgreSQL cluster (as the finder did): a database whose search_path names public, sales, with public.customers, sales.orders and sales.customers. Read back what introspectSchema and listRemoteTables answer, before and after.
    • packages/drivers/driver-sql/src/sql-driver.ts, the PostgreSQL branch only:
      • introspectSchema: each table's columns are read in its own schema (table_schema), not through columnInfo() on the bare name;
      • where one bare key would hold two same-named tables from different schemas, the answer says so loudly instead of dropping one;
      • introspectColumns where it reads the columns.
    • Pins in driver-sql's tests: the zero-column table reads its own columns, and a same-named pair is never silently collapsed.
    • The changeset: @objectstack/driver-sql.

    ⛔ Not in scope: a schema member on IntrospectedTable (a packages/spec contract decision with no measured consumer, per the triage); the SQLite and MySQL branches; service-external-datasource's listRemoteTables, which is read only, as the consumer.
    Container & model: M, mode:cloud (measured first on a live PostgreSQL cluster: the two defects before and after), model: TIER_DEFAULT (this run's dispatch-gates.mjs --tier: "no path-derived mandate")
    Clause-②: no (narrowing) (a multi-schema PostgreSQL listing that silently dropped a same-named table now says so loudly, which may refuse a listing that answered today; it owes a CONTRACT_REVIEW_TIER review on the narrowing face before the queue)
    Responsibility: an external Postgres datasource lists a table with no columns, or hides one of two same-named tables | introspection keys tables by bare name and reads columns through the first schema on the path | every external Postgres datasource whose search_path names more than one schema
    Thread-read: 6282275557
    Serial constraints cleared: open PRs' file lists and the pm:dispatched claims were read at this claim.


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Dispatch · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-20T13:40Z: mode:cloud. The dev session is session_013timUNNCthRfQkhy9Hz2fV. It builds on branch claude/issue-24707-pg-multischema-introspect, under claim 6292170810. The review of record is at CONTRACT_REVIEW_TIER, on the narrowing face.

    • Measured first: the card's database, search_path = public, sales, on a throwaway PostgreSQL cluster, read back through introspectSchema (and listRemoteTables if reachable without a server). The before is recorded ahead of any edit.
    • What lands, PostgreSQL branch only:
      • each table's columns read in its own schema, by table_schema, not by the bare name;
      • a same-named pair across schemas answered loudly: a refusal naming both schemas, or a reported collision. The dev settles which by the four axes and names every caller of introspectSchema; never a silent drop;
      • the single-schema and default (public) answer unchanged;
      • pins on a live PostgreSQL with an ablation, and a minor BREAKING changeset for @objectstack/driver-sql.
    • Not built: a schema member on IntrospectedTable, any packages/spec change, the SQLite or MySQL branches, or any edit to service-external-datasource. If the right fix needs one, the dev stops and reports needs_decision.
    • Serial constraint: PR feat(driver-sql,driver-turso,driver-memory,objectql,cli,metadata-protocol): plain organization key part in new unique indexes; the differ reads COALESCE(tenant) as tenant on a certified database (18.x step 7b-1 + 7b-2) #24893 edits sql-driver.ts in other regions; whichever lands second merges main.

    Generated by Claude Code

  4. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 24707,
      "status": "done",
      "branch": "claude/issue-24707-pg-multischema-introspect",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/24920",
      "session": "session_013timUNNCthRfQkhy9Hz2fV — this run's harness-stamped id (dispatched by domain:engine seat 1, session_01JtSQQ3EWsbNPzyYMug1tXy, under claim 6292170810)",
      "premise_still_valid": true,
      "summary": "Measured first on a throwaway PostgreSQL 16.15 cluster (local postgres binary, port 54724), the card's database with search_path = public, sales. At fcb40ad673, introspectSchema listed customers (public's 3 columns) and orders with columns [] and primaryKeys ['id']. sales.customers was absent, and listRemoteTables answered orders columnCount 0. Fix, PostgreSQL branch of SqlDriver.introspectSchema only: the listing also selects table_schema and its current_schemas(false) position. Each table's columns are read in its own schema (knex withSchema(schema).columnInfo() plus introspectColumnOrder with table_schema = ?, both via a new optional trailing schema parameter on the two protected methods; other callers' SQL is byte-identical). A bare name reached in more than one schema is refused with RESOURCE_CONFLICT / 409, naming each colliding table and its schemas in search_path order, plus the remedy (one schema per connection via the datasource's schema setting, or a rename). After, at 1eeab8089: the card's database answers the 409 through both introspectSchema and listRemoteTables. Without the twin, sales.orders reads its 4 columns (listRemoteTables columnCount 3 and 4). A single-schema control database's JSON answer (introspectedAt masked) has the same sha256 before and after, a273d463...f8. No spec change, no IntrospectedTable member, SQLite/MySQL untouched, service-datasource read only.",
      "tests": "Head 1eeab8089 (after merging origin/main a8b44d407). Before (fcb40ad673) / after measurement: see summary; probe script in the session scratchpad, not committed. New pin file packages/drivers/driver-sql/src/sql-driver-24707-introspect-pg-multischema.test.ts (live-PG cell via declareDialectCell), 4 pins: non-vacuity (path is both schemas; bare columnInfo reads [] for the later table), later schema's table reads its own columns (exact array), same-named pair refused ({code: RESOURCE_CONFLICT, status: 409} + message names table and both schemas), single-schema control (schema-scoped read equals bare read). Live PG on the merged head: 'Tests 4 passed (4)', exit 0. Ablation, fix committed, node scripts/ablation-replace.mjs, one leg per mutation, test imports ../src so no dist leg: leg A, anchor 'this.introspectColumns(tableName, schemaByTable.get(tableName))' replaced by the bare call ('anchor 1 to 0, blob 74cd151dd2be to 352e20d7e04b'): pin 2 red, 'Tests 1 failed | 3 passed (4)'. Leg B, refusal line deleted ('anchor 1 to 0, blob 74cd151dd2be to ca243e1d6171'): pin 3 red, 'expected undefined to be defined', 1 failed | 3 passed. Both legs: 'ok restored: blob == HEAD (74cd151dd2be) and git diff HEAD is empty'. pnpm --filter @objectstack/driver-sql typecheck: exit 0 on b61cf5026 and on 1eeab8089 (tsconfig include src/**/*, so the test is covered). pnpm --filter @objectstack/driver-sql test (full, no live URL): exit 0, 'Test Files 224 passed | 12 skipped (236)', 'Tests 3687 passed | 209 skipped (3896)', on b61cf5026 (the merge brought only platform-objects files). The 21 introspection-family live files with OS_TEST_POSTGRES_URL: exit 0, 'Tests 170 passed | 10 skipped (180)' (skips = MySQL cells). pnpm --filter @objectstack/service-datasource test (consumer), after building its dependency closure: exit 0, 'Test Files 48 passed (48)', 'Tests 862 passed (862)'. pnpm --filter @objectstack/core test:repo: exit 0, 'Test Files 6 passed (6)', 'Tests 82 passed (82)'. pnpm exec turbo run build --concurrency=2 on 1eeab8089: exit 0, 'Tasks: 77 successful, 77 total'.",
      "gates": "Local, head 1eeab8089: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths) derived 70 families over 3 paths (+271/-11). Every line was run with its exit code captured before any pipe: 70 x exit 0. Two families needed a second run. pnpm check:dual-build-cjs-loads first exited 3 (PREREQUISITE NOT MET, no dist) and was re-run after the full turbo build: exit 0, '112 published require entry point(s) across 69 package(s) load'. pnpm check:type-check-debt first hit my own 280 s per-command timeout (124) while the full build held the box, and was re-run: exit 0, '1 ledger entr(ies) re-measured ... none above its recorded number'. Reconciliation, node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran FILE (cmd :: exit N form): exit 0, 'Run reconciliation — 70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN', 'all 70 accounted famil(ies) carry one'. The clone was deepened first (git fetch --shallow-since=2026-07-13). check-changeset-no-major ran locally without --event, so it could not read the PR body's Clause-② line; CI's Check Changeset is the reading. CI check-runs on PR #24920: in_progress at report time, not waited for (os-dev contract). Line budget: none set. Diff per file: .changeset/24707-pg-multischema-introspect.md +19/-0; packages/drivers/driver-sql/src/sql-driver-24707-introspect-pg-multischema.test.ts +159/-0; packages/drivers/driver-sql/src/sql-driver.ts +93/-11.",
      "files_changed": [
        ".changeset/24707-pg-multischema-introspect.md (+19/-0)",
        "packages/drivers/driver-sql/src/sql-driver-24707-introspect-pg-multischema.test.ts (+159/-0)",
        "packages/drivers/driver-sql/src/sql-driver.ts (+93/-11)"
      ],
      "line_budget": "none set; +271/-11 over 3 files, per file as in files_changed",
      "deviations": [
        "Changeset level: minor with a BREAKING banner (FROM / TO / Fix), per the claim's 'Clause-②: no (narrowing)'. The measured fix DOES move an accept set: a multi-schema PG connection with a same-named pair answered introspectSchema successfully and now throws 409. So the narrowing is real, and no patch re-declaration is asked for. The zero-column half alone is a wrong answer made right; the changeset says so.",
        "Pins run in CI only in main's full battery: Temporal Conformance (live PG + MySQL), ci.yml step 'Run driver-sql suite against both live servers'. In the PR's Test Core lane the file is a named skip (no OS_TEST_POSTGRES_URL). Locally they ran on my own throwaway cluster.",
        "The prompt names the consumer 'service-external-datasource'. In this tree the package is @objectstack/service-datasource (packages/services/service-datasource, ExternalDatasourceService). That suite was run, and nothing in it was edited.",
        "#11201 and #24664 / PR #24706 were read through the in-tree record (sql-driver-11201-introspect-fk-schema-scope.test.ts, the IntrospectedForeignKey docblock, external-datasource-service.ts unattributedSchemaFilterError and its #24664 notes) and the card body, not by opening those threads.",
        "The throwaway PostgreSQL data directory lived at /tmp/os-issue-24707-pg, not in the scratchpad: the scratchpad's parent (/tmp/claude-0) is mode 700, which the postgres user cannot enter. It was stopped (postmaster PID 2522, the one I started) and deleted.",
        "Writes: git push to my branch ran 5 times (the empty branch, then WIP commits and the merge, per os-dev's commit-and-push-each-step rule). It counts as the one 'git push' budget line.",
        "Commit trailers: the AGENTS.md model-free pair (Claude-Session + Co-authored-by: Claude), per os-dev's rule that the repo's attribution outranks the harness reminder; the harness reminder named a model-bearing Co-Authored-By."
      ],
      "mcp_calls": "3 — mcp__github__issue_read (get #24707), mcp__github__issue_read (get_comments #24707), mcp__github__pull_request_read (get #24920, draft and assignee read-back). No MCP write tool.",
      "api_writes": "3 budget lines. (1) git push to claude/issue-24707-pg-multischema-introspect (5 pushes, branch only; not a REST write). (2) pr_create via scripts/pm/fleet-write/dispatch.mjs: POST /repos/objectstack-ai/objectstack/dispatches (request fw-20261020T143626Z-1ddc12, run 39440777841 success), executed by the relay as POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos/objectstack-ai/objectstack/issues/24920/assignees; read-back '9068 byte(s) sent, 9068 stored — identical', assignee os-project-manager. (3) This os-dev-report comment via scripts/pm/post-stamped.mjs: POST /repos/objectstack-ai/objectstack/issues/24707/comments.",
      "open_questions": [
        {
          "question": "Same-named table pair across schemas on one PG search_path: refuse, or keep the first and name the other?",
          "options": [
            "A — refuse introspectSchema with RESOURCE_CONFLICT / 409, naming each colliding table and its schemas in search_path order, plus the remedy (one schema per connection via the datasource 'schema' setting, or a rename)",
            "B — keep the first schema's table and report the other: with no spec change the only channel is a server log line",
            "C (outside the claim's two options, considered and rejected) — key the shadowed twin as a qualified 'sales.customers'"
          ],
          "recommendation": "A, built. Business need: only an external PG datasource whose search_path names 2+ schemas holding a same-named table can collide (the finder's measured shape). A single-schema connection, the default public deployment included, can never collide, so no internal caller that sees one schema is refused. Callers: ExternalDatasourceService listRemoteTables / generateObjectDraft / refreshCatalog / validateObject / testConnection, via ObjectQL.introspectDatasource or the driver directly. The admin connection test reaches it only as a last fallback after ping / checkHealth. No migration differ or os command calls it. All callers already surface a throw (testConnection answers {ok:false, error}). Long-term: B has no caller-visible channel without the spec member the triage withheld. Anti-AI-error: B's log line is invisible to HTTP/AI callers, i.e. the same silent drop. Startup focus: A adds no surface, option, key or gate. C is a context-dependent key spelling, the shape the IntrospectedForeignKey.referencedTable note (#11377) rejects."
        },
        {
          "question": "Which error code and status for the refusal?",
          "options": ["A — RESOURCE_CONFLICT / 409 (existing StandardErrorCode; two tables conflict on one key)", "B — VALIDATION_ERROR or DATABASE_ERROR / 400"],
          "recommendation": "A, built: an existing ledger code, no new error code. The conflict is between two remote objects on one key, not a malformed request."
        },
        {
          "question": "Prompt vs os-dev.md conflicts?",
          "options": ["none found"],
          "recommendation": "None. The prompt's 'service-external-datasource' is the in-tree @objectstack/service-datasource (see deviations)."
        }
      ],
      "out_of_scope_findings": [
        "carrier: none · noted, not filed (PR Acceptance notes) · read-only inference, not measured: introspectPrimaryKeys resolves by ?::regclass along search_path, so a VIEW or other relation named like a later schema's base table but sitting in an earlier schema would capture the primary-key read; the collision refusal judges base tables only · dedupe words: introspectPrimaryKeys regclass view shadows table search_path; pg primary key introspection wrong relation"
      ]
    }

    Generated by Claude Code

  5. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-20T16:05Z

    Probe Reading
    Review CONTRACT_REVIEW_TIER, narrowing face. Record 6294518392 PASS on 1eeab8089f. ACCEPT 6294549692. Not governed.
    Queue It entered the merge queue at 2026-10-20T15:33Z and left it merged at 2026-10-20T16:04Z. No gh-readonly-queue/main/pr-24920-* branch remains.
    origin/main 74fcec1edb is on origin/main, one squash commit carrying PR #24920's 3 files, +271 / −11.
    Card Closed completed through Fixes #24707; pm:dispatched is cleared.

    What main now holds:

    • On PostgreSQL, each introspected table reads its own schema's columns. A table that lives only in a later schema on the connection's search_path (sales.orders behind public) now reports its columns, where it reported none. listRemoteTables counts them, and a draft built from it has fields.
    • A table name reached in two schemas is refused, not silently dropped. introspectSchema throws RESOURCE_CONFLICT (status: 409), naming each colliding table with its schemas in search_path order, plus the remedy.
      • That pair is the driver's in-process contract.
      • The HTTP doors (the datasource admin routes and rest's external-datasource routes) answer it as 400 EXTERNAL_DATASOURCE_ERROR carrying the same message.
      • The boot-time federated validation rows the datasource's objects unreachable, with a warning; boot does not stop.
    • Who can meet it: only a saved external PostgreSQL datasource that sets no schema, on a server whose default search_path names two schemas holding a same-named base table.
    • The remedy: set the datasource's schema (it becomes the connection's search_path), or rename one table on the remote database. A deployment that needs both schemas uses one datasource per schema.
    • Unchanged: a connection that reaches one schema, the default public deployment included, answers exactly as before. SQLite and MySQL are untouched. There is no spec change.
    • Shipped as: minor of @objectstack/driver-sql, BREAKING (an accept-set narrowing), Clause-②: no (narrowing).

    Still open beside it:

    • The four live-PostgreSQL pins skip by name in a PR's Test Core lane. They run in main's Temporal Conformance battery, and its first run after this merge is their CI measurement. The seat reads it.
    • introspectPrimaryKeys resolves by ?::regclass along the path, so a view in an earlier schema named like a later schema's base table could capture the key read. That is an inference, not measured; it is noted in the PR, not filed.

    The dev session session_013timUNNCthRfQkhy9Hz2fV is archived.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

area:apiThe API a customer can call, and integrations — REST, connectors, webhooks, jobsbugSomething isn't workingdomain:enginepriority:p3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions