Skip to content

feat(driver-sql,objectql): qualify cross-schema foreign keys with referencedSchema; refuse to wire the bare name - #11906

Merged
os-warren merged 3 commits into
mainfrom
claude/issue-11377-cross-schema-fk-qualification
Aug 25, 2026
Merged

os-warren merged 3 commits into
mainfrom
claude/issue-11377-cross-schema-fk-qualification

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes #11377

Maintainer ruling (carried out; 引文保持原文,未翻译)

Provenance: maintainer, 2026-08-24, live PM chat, ruling on the four-facet analysis (issue comment 5393638385), verbatim:

接受你的建议。

The accepted recommendation, verbatim from that block:

推荐:选项 1(加可选 referencedSchema,越界时填),且同一张卡带消费端半边:util.ts 对带 schema 且不可解析的目标响亮跳过并标记,⛔ 不裸名接线(否则加了键没人读,声明无强制原地复发)。四轴同向。

Clause-②: yes (widens a public contract shape).

What landed

Driver half (packages/drivers/driver-sql/src/sql-driver.ts):

  • IntrospectedForeignKey gains an optional referencedSchema, present when — and only when — the referenced parent lives outside the session's resolution scope. referencedTable stays a bare name always (option 2's conditional spelling rejected as a trap; option 3 rejected as hiding truth). The interface TSDoc states the per-arm semantics.
  • PG arm: joins the parent's namespace and projects its schema when it is not in current_schemas(false) — the same search_path scoping driver-sql (PG): introspectForeignKeys' information_schema query is not schema-scoped — a same-named table in another schema contributes its foreign keys #11201 gave the child side, asked about the parent. Measured on live PostgreSQL 16.13 with the card's fixture shape before wiring: a parent in os11377_far answers referenced_schema = os11377_far with the table name bare; an in-path parent answers NULL.
  • The key is absent (never undefined) for an in-scope parent — presence itself carries the fact.

Consumer half (packages/objectql/src/util.ts — same PR, per the ruling):

  • The local IntrospectedForeignKey mirror gains the same key (consumer-side copy of the producer contract, the isUnique pattern).
  • convertIntrospectedSchemaToObjects refuses to wire a lookup for a foreign key carrying referencedSchema: a loud flag through the new options.logger (default console, so a bare call is loud) names the constraint, the qualified address, the consequence, and the remedy; the column converts as a plain field so its data stays visible while the false address does not ship. Foreign keys with in-scope targets keep wiring byte-identically (whole-object pins). The warn idiom was measured from the package (action-governance.ts's minimal injected-logger surface, matching PluginContext.logger); util.ts itself had none.

MySQL-arm measurement (confidence-gap check folded in from the four-facet block)

The MySQL arm can fill the key symmetrically, and now does: information_schema.KEY_COLUMN_USAGE carries the parent's database as REFERENCED_TABLE_SCHEMA on the same row the arm already reads; InnoDB permits cross-database foreign keys; and the child-side filter TABLE_SCHEMA = DATABASE() never constrained the referenced side — so a cross-database foreign key was already being returned, bare. The arm now projects the parent's database when it differs from DATABASE(), compared with MySQL's null-safe equality operator (with no default database selected, no bare name resolves, so every parent is qualified). No live MySQL exists in this container: the new test file's live-MySQL cell runs in CI's Temporal Conformance (live PG + MySQL) job (a named skip elsewhere, per the matrix contract), and a no-server emission probe (the #11379 pattern) pins the projection and the TS row mapping in every job. #11379's separate MySQL ordering issue was not touched.

SQLite: never fills the key — no schemas, and a foreign key cannot cross an ATTACHed database; documented in the interface TSDoc.

Reverse verification (RED first, both halves)

Fixture: live PostgreSQL 16.13 in this container; the card's shape — a _far-suffixed sibling schema holding os11377_remote_parent, referenced from an in-path child, with an in-path parent as the sibling control.

  • (a) driver, cross-schema pin — RED by the key's absence against pre-fix source: toStrictEqual diff shows the expected referencedSchema line missing from the received answer (2 PG tests failed: the direct pin and the introspectSchema carry-through; the MySQL emission-probe pins failed alongside — 4 failed, 1 passed [the fixture control], 1 skipped). GREEN after: 5 passed, 1 skipped (live MySQL, named skip).
  • (b) driver, in-path pin — byte-identical answer with NO referencedSchema key, asserted with toStrictEqual plus an explicit Object.keys absence read (toEqual would accept referencedSchema: undefined). Preservation pin: green on both sides by design.
  • (c) consumer — RED against pre-fix util.ts: all 4 new pins failed (a lookup wired to the bare name, no flag); the 22 pre-existing tests stayed green. GREEN after: 26/26. The flag message and meta are pinned fragment by fragment.
  • (d) consumer, resolvable FK — whole-object byte-identical lookup, exactly one warn emitted (the cross-schema refusal, not about the resolvable key). Preservation pin.

Each mutation and each restore was confirmed on disk before reading any run: grep -c referencedSchema (driver: 0 pre-fix / 5 fixed; consumer: 0 pre-fix / 9 fixed) and git status --porcelain (lone unstaged M during the leg; clean after restore). Both RED legs ran from committed fixes via git restore --source=$BASE (tree-only), never against uncommitted work.

Verification (local; head d67d937)


Generated by Claude Code

claude added 3 commits August 24, 2026 22:40
…chema (#11377)

IntrospectedForeignKey gains an optional referencedSchema, present when -
and only when - the referenced parent lives outside the session's own
resolution scope (PG: the parent's schema vs current_schemas(false);
MySQL: REFERENCED_TABLE_SCHEMA vs DATABASE(), null-safe). referencedTable
stays a bare name unconditionally. SQLite never sets the key - no schemas,
and a foreign key cannot cross an ATTACHed database.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy
…erencedSchema (#11377)

convertIntrospectedSchemaToObjects reads the driver's new qualification:
a foreign key whose target carries referencedSchema is loudly skipped and
flagged through options.logger (default console) - never wired to the
bare name - and the column converts as a plain field so the data stays
visible. Resolvable foreign keys keep wiring byte-identically.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy
…chema pins; add changeset

The #11324 fixture IS the cross-schema shape #11377 qualifies, so its two
cross-schema assertions now carry referencedSchema; presence/absence
semantics stay pinned in the #11377 file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

5 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 45 of 222 client-bound route-ledger rows — the other 177 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: 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.

Coarse fallback — 18 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 fd50e59e722dadbfe8961b265d79cb4e38a0ca74 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 90f40f85cee6360cb47dd6cb11579b37611efdd0 — the merge of head d67d93793e27c24d90698e309d2e2535844b0e7d into base fd50e59e722dadbfe8961b265d79cb4e38a0ca74, 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 90f40f85cee6360cb47dd6cb11579b37611efdd0 && git checkout 90f40f85cee6360cb47dd6cb11579b37611efdd0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fd50e59e722dadbfe8961b265d79cb4e38a0ca74 d67d93793e27c24d90698e309d2e2535844b0e7d && git checkout -B drift-repro fd50e59e722dadbfe8961b265d79cb4e38a0ca74 && git merge --no-ff d67d93793e27c24d90698e309d2e2535844b0e7d

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

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

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/l tests tooling

Projects

None yet

2 participants