Repository navigation
frontend: refuse a relation Postgres reads from a system schema (#202) - #224
Merged
Merged
Conversation
A bare `pg_attribute` reads `pg_catalog.pg_attribute`, which Postgres searches before the search path, even after `CREATE TABLE pg_attribute` has put a table of that name in `public`. The frontend lowered the reference against the declared table and its key, so `SELECT DISTINCT attname` and `SELECT attname` lowered as one query, and qed and sqleq-solver proved a non-equivalent pair. `safe_to_strip` stopped the schema strip on a `pg_*` name but refused nothing: the name was left as written, and `Catalog::find` matched the DDL's table. Refuse, as `unsupported`, a relation reference that is a bare `pg_*` name or that is qualified by a system schema (`information_schema`, or any schema under the `pg_` prefix Postgres reserves): - in `lower::factor_instance`, the one place a table becomes a scan, before the catalog lookup; - on INSERT targets in `dml::insert_pair`, before the declared short-circuit, and on the key a bare target resolves to, since the target is replaced by its source before lowering; - on INSERT targets in sqleq-lean, which keys tables by bare name. `safe_to_strip` now treats every `pg_` schema as a system schema, so `pg_toast.t` and `pg_temp_3.t` are no longer stripped to the DDL's `t`. The rule is Postgres's `pg_` prefix, not a list of one major's catalog relations. Postgres documents the prefix as its convention for system relations, and a stale list would fail in the unsound direction. On Postgres 17.11, no pg_catalog relation lacks the prefix. Pins moved: none. There are four new pins under tests/pairs/names. On main, qed and sqleq-solver prove three of them although they are not equivalent. Two of those three (the pg_catalog-qualified pair and the pg_temp_3 pair) raise no portfolio alarm, because sqleq-fuzz cannot create their DDL. Corpus A/B (90,145 rows, declared and infer-seeded): emitted and reflexive counts are unchanged, and IR is byte-identical for every pair both builds emit. 12 rows (declared) and 13 rows (infer-seeded) move from refuse:schema (unknown table) to refuse:unsupported. Environment: Datadog workspace Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
`system_relation` is public through the `internals` re-export, so its doc links to the crate-private `system_schema` and `safe_to_strip` failed `cargo doc --features internals` under `-D warnings` (rustdoc::private_intra_doc_links). Name them as plain code instead, and make `system_schema` crate-private, since nothing outside `normalize` reads it. Environment: Datadog workspace Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
bvolpato-dd
approved these changes
Nov 2, 2026
bvolpato-dd
left a comment
There was a problem hiding this comment.
Approved under Bruno Volpato's standing instruction for datadog-labs/sqleq. I checked the pull request metadata only; I did not review the diff or run the tests.
Stamped by nightwatcher, a review agent that acts for Bruno Volpato.
This was referenced Nov 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What type of PR is this?
Description
Fixes #202.
Why
Postgres searches
pg_catalogbefore the search path unless the path names it. So afterCREATE TABLE "pg_attribute", which puts a table inpublic, both queries readpg_catalog.pg_attribute. On a fresh Postgres 17.11 database, side A returns 1188 rows, side B 3146, andpublic.pg_attributeholds 0.The frontend lowered the reference against the declared table and its key, so
DISTINCTwas redundant and qed and sqleq-solver proved the pair:resolve_tablesaskssafe_to_strip, which isfalsefor any barepg_*name.ResolveTablethen leaves the name as written. That guard only stops the schema strip; it refuses nothing.lower::factor_instancethen finds the declared table withCatalog::find.dml::find_target's doc stated the assumption that broke: that a reference the guards leave unstripped "then fails to resolve" inlower.Two more channels the issue does not name
Neither raises a
--portfolioalarm onmain. sqleq-fuzz cannot create their DDL and answerserror, so the false proof is silent.create table "pg_catalog"."pg_attribute" (…)withFROM "pg_catalog"."pg_attribute"lowered against the declared key. Postgres refuses thatCREATE TABLE, so the queries always read the catalog. The issue's proposed exemption ("qualified by a schema the DDL declares it in") would have let this through.safe_to_stripmatched qualifiers against a fixed list (pg_catalog,information_schema,pg_temp,sys). Sopg_toast.tandpg_temp_3.twere stripped totand lowered against the DDL'st.pg_temp_3.tis a session's own temporary table, which is a real witness.The rule
A relation reference names a system relation, and is refused as
unsupportedwhatever the DDL declares, when either holds:pg_;information_schema, or any name starting withpg_, a prefix Postgres reserves for its own schemas (CREATE SCHEMA pg_xis an error).A
pg_*table in a user's schema, read through that schema (public.pg_attribute,s.pg_x), is the user's table and still lowers. AWITHbinding named like a catalog relation is the binding, as in Postgres;inline_ctesremoves it before lowering.The rule is the prefix, not a per-major list of catalog relations:
pg_prefix so that they cannot collide with user tables that avoid it.pg_catalogrelation lacks the prefix (SELECT count(*) FROM pg_class WHERE relnamespace = 'pg_catalog'::regnamespace AND relname NOT LIKE 'pg\_%'is 0).PG_CATALOG_TYPESis a list only because types share no prefix.Where it is checked
normalize::system_relation(withsystem_schemaandsystem_key) implements the rule, besidesafe_to_strip.safe_to_stripusessystem_schemafor qualifiers, so nopg_schema is stripped.sysis kept.lower::factor_instancerefuses before the catalog lookup. This is the one place a table becomes a scan, so it covers declared, raw-DDL, corpus and inferred catalogs, and theUPDATE/DELETEreductions, whose targets andUSING/FROMlists becomeFROMitems.dml::insert_pairrefuses anINSERTtarget, because the target is replaced by its source and never reaches lowering. The check runs before the short-circuit that skipssafe_to_stripfor declared targets. It also covers the key a bare target resolves to (tto a declaredpg_catalog.t).INSERTtargets inrecognize::extract, since its schema keys tables by bare name. The helper is re-exported throughinternals.docs/SOUNDNESS.mdgains the rule, why it is a prefix, and what it costs.What it costs
Each of these is a refusal where Postgres would read the user's table:
pg_*table read bare (read through its schema, it lowers);pg_temp.t;information_schema;Undeclared catalog relations change refusal kind, from
schema("unknown table") tounsupported. Postgres accepts those queries, sounsupportedis the right kind undertests/refusal_kinds.rs's rule.Pins that moved
None. Four new pins under
tests/pairs/names/, blessed on frontend, fuzz, qed, sqleq-solver and lean:29c3d05)catalog_relation_shadows_declared_table.sql(the issue's pair)proved, sqleq-solverproved, fuzzcounterexample: alarmrefuse:unsupported, no-plancatalog_qualified_declared_table.sqlproved, sqleq-solverproved, fuzzerror: silentrefuse:unsupported, no-plantemp_schema_qualifier_not_stripped.sqlproved, sqleq-solverproved, fuzzerror: silentrefuse:unsupported, no-planpublic_qualified_pg_named_table.sql(control)emit, provedHow it was tested
29c3d05, whose frontend matchesmain): 0/4 hold, with six false proofs, as tabled above.tests/system_relations.rsruns each channel through both DDL readers and all three catalog modes: bare names, system-schema qualifiers,INSERT/UPDATE/DELETEtargets, and a bare target resolving to a declaredpg_catalog.t. Its controls arepublic.pg_attribute,s.pg_x, anINSERTintopublic.pg_attribute, and aWITH "pg_class"binding.normalizecover the rule and thepg_toast/pg_temp_N/pg_toast_temp_Nstrip cases. sqleq-lean has a unit test for the refused targets.tests/refusal_kinds.rsgets a row:SELECT "relname" FROM "pg_class"prepares on Postgres 17 and is refusedunsupported.cargo test --workspace: 1301 passed, 0 failed.cargo clippy --all-targets,--features internals --all-targetsand-p sqleq-lean --all-targets, each with-D warnings: clean.sqleq-check --expect pinned --axes frontend,qed,sqleq-solver,fuzz,lean tests/pairs examples/*.sql: 473/475. The two that fail areaggregates/scalar_agg_empty_group.sqlandaggregates/unlisted_var_pop_under_exists.sql, where qed saysprovedagainst a pinnedno-proof. Their IR is byte-identical before and after this change, and a pre-fix build fails them the same way with the sameqed-prover(the newest of several builds in the local Nix store). They come from that prover build, not from this PR. Every other axis holds on both.CREATE TABLEinpg_catalogandpg_toastis refused, andCREATE SCHEMA pg_xis rejected.WITH pg_classbinding shadows the catalog.search_path = public, pg_catalogdoes reach the user's table, which is the opening frontend: resolve table names through a declared search path, so a baretands.tcan be one table #91 could later use.Frontend A/B over the benchmark corpus. The corpus is private, so these counts are for scale only. The
mainand post-fixsqleq-frontendwere run over all 90,145 rows, under the declared and the inferred-seeded catalogs, and compared row by row:refuse:schematorefuse:unsupported, each with the new system-relation reason. Two of those rows are in the latest all-axes run, and stay refused.This change only refuses, so it shrinks the provable set, and rule 2's cross-check does not apply.
Checklist
sqleq-check --portfolio(the provers and
sqleq-fuzzat once; a proof with a counterexample is analarm), and noneof the newly proven pairs has a counterexample. (It only refuses; the corpus A/B shows no row gaining a proof.)
tests/pairs/and was seen failing on a build from before the fix.
tests/pairs/that moved is explained in the description. (None moved.)LICENSE-3rdparty.csvwas regenerated withsh tools/update_license_3rdparty.sh. (No dependency change.)Out of scope
tands.tcan be one table #91: a declared search path namingpg_catalogafter the user's schemas could later relax the bare-name rule.PG_CATALOG_TYPEShas the same stale-list hazard for domains, and is worth its own issue.AI code assistants
Assisted-by: Claude Code (Claude Opus 5.5)
🤖 Generated with Claude Code