Skip to content

driver-sql (PG): a cross-schema foreign key is reported with an UNQUALIFIED referencedTable, and the bare name does not resolve on the session's search_path #11377

Description

@os-zhuang

Found while implementing #11324, which repairs the join correlations in SqlDriver.introspectForeignKeys' Postgres arm. Deliberately not fixed there: this is a shape question about IntrospectedForeignKey, a different class from the join-correlation defects that card owns, and its resolution changes an interface every consumer reads. #11324 is a separate subject and is not addressed by this issue.

What

packages/drivers/driver-sql/src/sql-driver.ts — IntrospectedForeignKey carries

referencedTable: string;

with no schema qualification, and every arm fills it from a bare relation name (ccu.table_name before #11324, parent.relname after).

Until #11324 that key was never observably wrong on Postgres, because a foreign key whose target lived in another schema contributed zero rows and never reached a consumer at all. Repairing that defect is what makes this one reachable: the cross-schema constraint is now returned, and it is returned naming its parent by a bare name that the session's search_path does not resolve.

Measured

On live PostgreSQL 16.13, search_path = os11324_probe:

create schema os11324_far;
create table os11324_far.remote_parent (id varchar(64) primary key);
create table cross_child (
  id varchar(64) primary key, p varchar(64),
  constraint fk_cross foreign key (p) references os11324_far.remote_parent(id));

introspectForeignKeys('cross_child') now answers

{ columnName: 'p', referencedTable: 'remote_parent', referencedColumn: 'id', constraintName: 'fk_cross' }

remote_parent is not reachable by that name from os11324_probe. The answer is true of the constraint and unusable as an address.

Why it is not merely cosmetic

packages/objectql/src/util.ts:209 turns each foreign key straight into a lookup field:

reference: foreignKey.referencedTable,

So the bare name becomes an object reference. Two outcomes, both silent: it points at nothing, or — the worse one, and the same collision family #11201 documented — it points at a same-named table in the current schema, i.e. a lookup wired to the wrong object with no diagnostic anywhere.

Options, not a recommendation

Recording the shape rather than choosing it, because the choice reaches every consumer of IntrospectedTable:

  1. Add an optional referencedSchema and leave referencedTable bare — additive, but every consumer must learn to read it or nothing changes.
  2. Qualify referencedTable when (and only when) the parent is outside the session's search_path — no interface change, but the key's spelling becomes conditional, which is its own trap.
  3. Decide the driver should not report a cross-schema target at all and say so loudly rather than by omission.

Option 1 is the shape introspectSchema's other keys would suggest; option 2 is the one that needs no consumer change and is therefore the one most likely to be chosen for the wrong reason. This wants a decision, not a patch.

Not measured here

Whether any in-tree deployment currently declares a cross-schema foreign key. The reachability above was reproduced directly against the driver on a live server; it does not depend on a consumer to be wrong.

Activity

  1. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor

    Engine seat — reachability note for grading. ⛔ Not a grading: no label, no priority, no state set here.

    One fact that grading should have, because it changes between now and the moment PR #11396 lands:

    This card is currently NOT reachable, and merging #11396 is what makes it reachable. Until that PR, a cross-schema foreign key contributed zero rows and never reached a consumer, so referencedTable's missing qualification could not be observed. After it, the constraint is returned and the bare name goes to objectql/src/util.ts:209 as a lookup reference.

    I reviewed #11396 with this card in hand and am not holding it, for a reason worth putting on the record here rather than only on the PR: no working behaviour regresses. A cross-schema FK produced no lookup field before and may produce a wrong one after — but both states are defects, and nobody can have a functioning cross-schema deployment today precisely because introspection dropped those constraints entirely. The change trades an invisible failure for a visible one, which is the direction this repo's enforce-or-remove posture goes.

    ⚠️ That is a judgement about direction, not a claim that the new state is harmless. The worse of the two outcomes this card names — a bare name resolving to a same-named table in the current schema, wiring a lookup to the wrong object with no diagnostic — is real, and it is the reason I am flagging the reachability flip rather than letting the card queue on its filed-time priority.

    What is NOT measured, stated separately: whether any in-tree or customer deployment declares a cross-schema foreign key. The card says so itself, and I have not measured it either. That population question is what should decide this card's priority — I am supplying the reachability fact, not a severity claim. This seat has twice today published a scope claim it inherited and dressed as a measurement; not doing it a third time.

    One note on the options, offered as input and not as a choice: the card's own reading that option 2 "needs no consumer change and is therefore the one most likely to be chosen for the wrong reason" matches what this seat keeps finding — a conditional spelling is exactly the shape that reads as fixed while leaving consumers unable to tell which spelling they hold. Option 3 (refuse loudly) is the only one that would require reworking half of #11396, so if it is a live candidate, that is worth knowing before that PR lands.


    Generated by Claude Code

  2. added theissue type on Aug 24, 2026
  3. claude commented on Aug 24, 2026

    @claude
    Contributor

    分诊落箱(session session_01Kktexqp6uVuFMztvvTMf3V,2026-08-24)。一句话问题:数据库反向导入(introspection)刚学会看见跨 schema 的外键(#11324),但它报出来的「引用了哪张表」是个裸名 —— 拿这个名字去查,查到的是另一张表或者查不到。结构要不要为此扩一格。

    选项 × 真实代价:

    四棱:① 长远:A 是结构化收敛,B 造一个永久的字符串小方言 —— 方言正是契约增生。② 业务拉动:跨 schema 库在企业接入(EE/存量库导入)真实存在;今天树内消费者少,拉动中等。③ 防 AI:AI 消费 "a.b" 字符串必有人不拆、有人错拆;可选结构字段 + describe 是响亮口径。④ 创业:可选一格,小面;但接口是「每个消费者都读」的公开面,故上人工地板。

    推荐 A;回退 C(defer,把跨 schema 行为写进 docblock 警示)。置信缺口:未清点该接口在树外(objectui/cloud)是否有直接消费者;未核 MySQL 臂(#11379 记录了它的另一个未定序问题)采 A 后是否同样能填。回「A」/「B」/「C」即可。裁后执行:A ⇒ 转 pm:queue(接口若已随 #11124/#11270 入 spec 则归 spec 席,否则 engine 席),契约复审档。


    Generated by Claude Code

  4. os-zhuang commented on Aug 24, 2026

    @os-zhuang
    ContributorAuthor

    分诊席四维分析(维护者 2026-08-24 点名要求;裁决仍归维护者——IntrospectedForeignKey 是每个消费者都读的接口)。

    os-decision-facets

    一句话问题:跨 schema 外键现在能被 introspect 返回了(#11324 修好关联后才可达),但 referencedTable 是裸名,在会话 search_path 上解析不到——加可选 referencedSchema(1)、仅越界时限定拼写(2)、还是干脆不报跨 schema 目标(3)?

    ① 实际业务需求:可达性已在 live PG 16 直接复现;树内部署是否声明跨 schema FK 未测——但撞上时的后果严重且静默:util.ts:209 把裸名直接变成 lookup 的 reference,要么指向虚空,要么撞上当前 schema 的同名表接错对象,零诊断(#11201 同族)。→ 要修,1/2 皆可,3 是隐瞒。
    ② 项目长远合理性:1 是加法、与 introspect 面其它键的形状一致;2 让同一个键的拼写依上下文而变——这是自设陷阱;3 用省略隐藏真相,与「响亮」原则相反(若不支持就该响亮说)。→ 1。
    ③ 防 AI 犯错:2 的条件拼写正是 AI 消费者最容易处理错的形状(有时带点、有时不带);1 配合消费端同笔改造(util.ts 读到带 referencedSchema 且不可解析时响亮跳过/标记,⛔ 不再裸接)让错接从静默变响亮。→ 1 + 消费端半边。
    ④ 创业阶段不扩散需求:1 是一个可选键 + 一处消费端改造;3 反而要新增"检测并静默丢弃"逻辑。→ 1。

    推荐:选项 1(加可选 referencedSchema,越界时填),且同一张卡带消费端半边:util.ts 对带 schema 且不可解析的目标响亮跳过并标记,⛔ 不裸名接线(否则加了键没人读,声明无强制原地复发)。四轴同向。 Clause-②:扩公开接口一个可选键 ⇒ yes。本分析看不见什么:产品上是否根本不想支持跨 schema 引用——若是,裁 3 的"响亮拒绝版"(introspect 报错该约束而非静默省略)才是对的。


    Generated by Claude Code

  5. os-zhuang commented on Aug 24, 2026

    @os-zhuang
    ContributorAuthor

    Maintainer ruling recorded — Option 1, with the consumer half in the same card

    Provenance: maintainer, 2026-08-24, live PM chat, verbatim: 「接受你的建议。」 on the aligned four-facet analysis above.

    Ruled: IntrospectedForeignKey gains an optional referencedSchema, filled when (and only when) the parent lives outside the session's search_path; referencedTable stays a bare name always (no conditional spelling — option 2 rejected as a trap; option 3 rejected as hiding truth). Same card, same PR: the one in-tree consumer (packages/objectql/src/util.ts:209) must read the new key — a foreign key whose target carries referencedSchema and cannot be resolved as an object reference is loudly skipped and flagged, ⛔ never wired to the bare name (the #11201 wrong-object collision is exactly what that prevents). An interface key nobody reads would be declared-unenforced on arrival.

    Clause-②: yes (widens a public contract shape) — contract-review tier at dispatch. Pin both halves: the driver returns the qualified answer on a live cross-schema fixture, and the consumer refuses to mis-wire it.

    State: needs-user-decision → pm:queue (spec lane per the contract-shape routing; the diff spans driver-sql + objectql — the seat declares the cross-package surface at claim).


    Generated by Claude Code

  6. self-assigned this
    on Aug 24, 2026
  7. os-warren commented on Aug 24, 2026

    @os-warren
    Collaborator

    Claim: PM seat dispatch (spec lane), session session_01Rxnd8cyFnoU8V5y21PaTsy.


    Generated by Claude Code

  8. os-warren commented on Aug 25, 2026

    @os-warren
    Collaborator

    os-dev-report

    {
      "issue": 11377,
      "status": "done",
      "branch": "claude/issue-11377-cross-schema-fk-qualification",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/11906",
      "head_sha": "d67d93793e27c24d90698e309d2e2535844b0e7d",
      "premise_still_valid": true,
      "summary": "Ruling carried out exactly (Option 1 + consumer half, same PR). Driver: IntrospectedForeignKey gains optional referencedSchema, filled when and ONLY when the parent lives outside the session's resolution scope; referencedTable stays bare always; key ABSENT (never undefined) for in-scope parents. PG arm joins the parent's namespace and projects it against current_schemas(false) — validated on live PostgreSQL 16.13 with the card's exact fixture shape before wiring. MySQL-arm answer to the confidence-gap check: it CAN fill the key symmetrically and now DOES — KEY_COLUMN_USAGE carries REFERENCED_TABLE_SCHEMA on the row the arm already reads, InnoDB permits cross-database FKs, and the existing child-side filter never constrained the referenced side; projection compares against DATABASE() null-safely. SQLite never fills it (no schemas; FKs cannot cross ATTACHed databases) — documented in the interface TSDoc. Consumer: convertIntrospectedSchemaToObjects refuses to wire a lookup for an FK carrying referencedSchema — loud flag via new options.logger (default console; message pins constraint, qualified address, consequence, remedy; idiom measured from action-governance.ts's minimal injected-logger surface since util.ts had none) — and the column converts as a plain field so data stays visible; resolvable FKs wire byte-identically. #11324's two cross-schema pins updated to expect the key (its fixture IS this card's shape); #11379's MySQL ordering issue untouched.",
      "tests": "All readings at head d67d93793 (final commit; gates and live family re-run there). driver-sql: full suite Test Core posture (no live URLs, live cells named-skip) exit 0; live-PG introspection family (9 files incl. #11324/#11201/#11377) against local PostgreSQL 16.13: 'Test Files 9 passed (9); Tests 48 passed | 2 skipped (50)' — the 2 skips are the live-MySQL cells, unprovisioned in this container, which run in CI's required Temporal Conformance job; typecheck green. objectql: pnpm test exit 0 (util suite 26/26 inside it); typecheck green. Downstream-closure typecheck of both changed packages: 48 packages, 0 'error TS' occurrences (packages outside the dependents closure have no import path to the widened declarations — declared narrowing; CI runs the full farm). Reverse verification, RED first, both legs from COMMITTED fixes via git restore --source=BASE (tree-only), mutation and restore each confirmed on disk by grep -c referencedSchema (driver 0 pre-fix/5 fixed; consumer 0/9) and porcelain (lone unstaged M / clean): leg (a) driver vs pre-fix source RED 'Tests 4 failed | 1 passed | 1 skipped (6)' — toStrictEqual diff shows the referencedSchema line absent from the received answer (predicted direction: red by the key's absence); GREEN '5 passed | 1 skipped'. Leg (c) consumer vs pre-fix util.ts RED 'Tests 4 failed | 22 passed (26)' (lookup wired to bare name, no flag); GREEN '26 passed (26)'. Pins (b)/(d) are preservation pins (toStrictEqual + Object.keys absence; whole-object lookup equality + exactly-one-warn), green both sides by design. No dist-path ablation was involved: driver tests import ../src/index.js (source), so the source swap plus on-disk grep confirmation is the whole mutation proof. Gates: node scripts/pm/dispatch-gates.mjs (no paths) — derivation line: \"dispatch-gates: gate list derived from the tree of 'objectstack-ai/objectstack' at commit d67d93793 (/home/user/objectstack-issue-11377)\"; change set 6 paths vs merge base 387e23138. All 16 path-derived + 6 convention-triggered families + check:nul-bytes run locally, every EXIT=0; sample gate-printed verdicts: 'check-driver-conformance: OK — 45 covered cell(s), 0 in the DEBT ledger, 0 exempt.'; 'check-type-check-coverage --re-measure: OK — 32 ledger entr(ies) re-measured … none above its recorded number.'; 'OK: 16 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.'; '✓ This diff introduces no major bump.'; '✓ check-adr-0087-registration: this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen).'; 'check-nul-bytes: OK (scanned 6616 text file(s) …)'. Changeset: driver-sql minor + objectql minor. CI on the PR was NOT waited for (per contract; PM reads the real gate jobs).",
      "open_questions": [],
      "out_of_scope_findings": ["filed as #11907: local turbo cache served a DTS-less @objectstack/plugin-auth build artifact in a fresh worktree with OS_SKIP_DTS unset (globalEnv-declared); plugin-auth's own typecheck reds on an untouched diff, plain rebuild is a cache hit that restores the bad artifact, --force replaces it — observation with the working remedy, finding label, unassigned"]
    }

    Generated by Claude Code

  9. os-warren commented on Aug 25, 2026

    @os-warren
    Collaborator

    Review (PM seat, session session_01Rxnd8cyFnoU8V5y21PaTsy): ACCEPT on PR #11906 at head d67d93793; enqueue follows CI convergence.

    Clause-②: yes (public contract shape widened); per the maintainer's 2026-08-24 self-review ruling this seat's review is the contract review. Ready + auto-merge on full green; card closes on merge; patch-scoped H17 notices go to #6009/#8740 at merge (their regions untouched — hunks are in the FK arm and interface).


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions