schemadiff: export ListManagedTables so owners enumerate declared tables the way pull does - #92
Conversation
…les the way pull does Undeclared-table detection is the owner's job, and the recipe in docs/capabilities.md asked each owner to hand-copy the catalog query that pull uses to baseline a schema. Two copies of a fail-closed enumeration drift, and every false positive blocks a table nobody touched. schemadiff.ListManagedTables is that query, exported: ordinary and partitioned tables in one schema, partitions excluded because their parent's PARTITION BY represents them, extension-owned tables excluded because they have no file to write, sorted by name. Every catalog object and operator is pg_catalog-qualified so search_path shadowing cannot turn the enumeration into a silent empty result. pull now calls it, its private copy is gone, and the capabilities recipe cites the function.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
…nsion exclusion live ListManagedTables is a live-catalog query that returns the tables a schema directory is expected to account for, which is a superset of the tables Render can export: partitioned parents, either side of INHERITS, unlogged tables and FK-referenced tables are listed and then refused by pull, one REFUSED line per table. The doc comment and the capabilities entry described the result as the tables "represented by files in a declarative schema directory", and the capabilities entry also said INHERITS children are listed "because each has its own declaration", which Render's refusal contradicts. Both now describe the candidate set, name the shapes Render refuses, and say pull reports each refusal by table. The extension-member exclusion was only asserted by searching the SQL text for the deptype predicate. It is now proven behaviourally: a throwaway schema gets two tables, one is attached to plpgsql with ALTER EXTENSION ... ADD TABLE, and ListManagedTables must return exactly the unattached one. Breaking the deptype filter makes the attached table leak into the result and the test fails. Cleanup detaches the table with a non-cancelled context before the schema drop, since extension members cannot be dropped through the schema. Both tests in the file now close the pool from t.Cleanup so it is still open when the schema drop and the detach run. 🤖 Generated with Amp (Claude Opus 4.6)
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
🤖 Reviewed The hardening works, and I checked it live. On a stock PostgreSQL 16 I built the shadowing attack the doc comment describes — a schema containing a table named
Also correct: 1.
|
| Mutation | Result |
|---|---|
all 7 OPERATOR(pg_catalog.=) → = in listManagedTablesSQL |
SURVIVED |
'pg_catalog.pg_class'::pg_catalog.regclass → 'pg_class'::regclass |
SURVIVED |
Every semantic predicate is well pinned — dropping NOT c.relispartition dies in 3 tests, dropping the deptype = 'e' exclusion dies in the extension test, 'e'→'x' dies, collapsing the relkind disjunction either way dies, removing the schema filter dies in 6, removing or reversing ORDER BY dies. The qualification is the one property in the file with no test at all, and it is the property the PR body leads with.
The probe above is the missing test, and it fits the existing harness. On a database of its own (see finding 1), before the listing:
_, err = pool.Exec(t.Context(), fmt.Sprintf(`
CREATE SCHEMA shadow;
CREATE TABLE shadow.pg_class (oid oid, relname name, relnamespace oid,
relkind "char", relispartition bool);
CREATE FUNCTION shadow.always_false(oid, oid) RETURNS boolean
AS $$ SELECT false $$ LANGUAGE sql IMMUTABLE;
CREATE OPERATOR shadow.= (LEFTARG=oid, RIGHTARG=oid, FUNCTION=shadow.always_false);
ALTER DATABASE %s SET search_path = shadow, pg_catalog;`, ...))ALTER DATABASE … SET search_path is the part that makes this work through a *pgxpool.Pool: ListManagedTables takes the pool and picks whichever connection it likes, so a session-scoped SET on one connection proves nothing. Naming pg_catalog explicitly and second is also essential — left implicit it is searched first and no shadowing is possible, which is why this is not reachable from the tests as they stand. With that in place, a fresh pool listing the schema still returns the right names, and both mutations above die.
3. "a silent empty result" is one of the two failure modes, and the other one is the one this file says matters
managed_tables.go:39-41 (and docs/capabilities.md:339-340) justify the qualification as stopping a shadowed search_path from turning the listing "into a silent empty result." That is true of the operator shadow — my run A returned 0 rows. It is not what the regclass shadow does. With the operator left intact and only pg_class shadowed:
-- old SQL: ordinary, owned_by_ext <- extension member leaks in
-- this PR's SQL: ordinary
Resolving 'pg_class'::regclass to a decoy makes d.classid compare against an oid nothing matches, so the NOT EXISTS is unconditionally true and every extension-owned table is listed. That is a false positive, not an empty result — and false positives are precisely the harm the PR body and capabilities.md:319-320 name ("every false positive blocks a table nobody touched"). Both hardenings are right; the sentence explaining them describes the mode that matters less to the caller. Naming both failure modes would make the comment carry its own justification.
4. Three error sites now return the same string, where the code being replaced returned three
managed_tables.go:45, :53 and :58 are byte-identical: fmt.Errorf("list managed tables in schema %q: %w", schema, err). The listTables this replaces distinguished them — "list tables in schema %s", "scan table in schema %s", "read tables in schema %s" (internal/cli/pull.go:82, :89, :94 on main). A query that never opened, a row that failed to decode, and a stream that broke mid-read are three different situations for whoever reads the error, and the wrap is where that distinction lived. Since the function is now a public entry point whose errors reach code that has no other context, this is a step backwards; keeping the three verbs costs nothing.
5. Smaller
- Removing the
rows.Err()check (:57-59) and removingdefer rows.Close()(:47) both survive. I'd call neither a defect — pgx surfaces most stream failures throughScan, and the connection returns to the pool onNextexhaustion — but they are the two remaining unpinned lines in the file, so they belong in the tally rather than being read as coverage. - The multi-statement
pool.Execat:67-70runs under the simple protocol's implicit transaction, so theALTER EXTENSIONcannot half-apply and leave a member table with no registered cleanup. Worth knowing, given finding 1 — it closes the one window that would otherwise sit between theADDand thet.Cleanupregistration. - All 14 checks are green on this head across PostgreSQL 14–18, and the branch already carries
main(4531008).
This review was generated by Claude Code (claude-opus-5).
|
🤖 Second pass — the registry, the docs, and the stack. InvariantsNo entry in 1. The stated failure mode survives the fix: the recipe still carries its own copy of the SQLThe PR body's case is that "two hand-maintained copies of a fail-closed enumeration drift", and the evidence is strong — this PR is itself repairing that drift, since
The third is the one that stings: an owner who copies the block gets the pre-hardening query back, and This repo already reaches for a test when a doc and the code have to agree — the same reflex as the proof-type registry test in #91. Extracting the fenced 2. The refusal list at
|
aparajon
left a comment
There was a problem hiding this comment.
🤖 Approving. Exporting the enumeration is right, the pg_catalog qualification is real hardening (I reproduced both shadowing attacks live against a stock server), and every semantic predicate in the query is well pinned. Findings are in the two comments above — the plpgsql cascade in the new extension test and the untested qualification are the two worth acting on before this lands.
This stamp was left by Claude Code (claude-opus-5).
|
🤖 Adversarial review response — created by Kiran's code review agent (Amp, Claude Opus 4.6) — pull/92, follow-up commit Verdict: All findings from the scratch reviews at
Decisions to veto: (a) The shadowing test uses a per-connection Source: #92, review comments 5607854405 and 5607855411 at head |
schemadiff.ListManagedTablesexports the catalog querypulluses to enumerate a schema's tables, so owners detecting undeclared tables call the same enumeration instead of copying it.Why
Undeclared-table detection stays with the owner on purpose (
docs/capabilities.md), and the recipe there asked owners to run "the listingpullbaselines a schema from" themselves, with the SQL inlined. Two hand-maintained copies of a fail-closed enumeration drift, and a false positive in the owner's copy blocks a table nobody touched. One exported function is the single source for what counts as a table pg-sprite manages, and a doc test keeps the recipe's copy identical to it.What
schemadiff.ListManagedTables(ctx, pool, schema)returns the sorted names of ordinary and partitioned tables (relkindr/p) in one schema. Partitions are excluded because their parent'sPARTITION BYdeclares them; extension-owned tables are excluded because they have no file to write; views, materialized views, foreign tables and sequences are outside the declarative model.INHERITSchildren "because export refuses them anyway". They are now listed. An undeclared table is undeclared whether or notpullcan write its file, so anINHERITSchild with no desired file is a permanent finding for the owner until it is declared (through a hand-written file, sinceRenderrefuses inheritance) or dropped through a reviewed process. The listing is the candidate setpullenumerates, not the set of files it writes; refused shapes are reported per table.pg_catalog-qualified (OPERATOR(pg_catalog.=),'pg_catalog.pg_class'::pg_catalog.regclass). Under a user-firstsearch_path, an unqualified relation or=would join nothing and list no tables; an unqualifiedregclasscast would stop matching the extension dependency and list extension members as undeclared.pull's schema-existence guard is hardened the same way.pullcalls it; its privatelistTablesis deleted.docs/capabilities.mdcites the function, shows the hardened SQL, names both shadowing failure modes, and points at the model-limits paragraph instead of enumerating refusals;docs/schemabot-integration.mdmatches.introspectInTxdocuments why its own unqualified catalog queries are safe (it sets asearch_paththat omitspg_catalog, so the catalog is searched first).Tests
TestListManagedTables: ordinary, partitioned parent,INHERITSparent and child, unlogged listed; partition, view, materialized view, sequence not.TestListManagedTablesExcludesExtensionOwnedRelations: on a throwaway database,ALTER EXTENSION plpgsql ADD TABLEgives a table a realdeptype = 'e'dependency; the listing leaves it out. Skips on SQLSTATE42501when an externalPG_DSNrole does not own the extension.TestListManagedTablesResistsCatalogShadowing: empty impostorpg_class/pg_namespace/pg_depend, always-false=over(oid,oid),(name,name),("char","char"), and aregclassdomain, all ahead ofpg_catalogon the connection'ssearch_path, with an extension member present; the listing is still exact. Every un-qualification of the query fails it.TestCapabilitiesDocShowsTheManagedTablesQuery: the recipe's fenced SQL block equalslistManagedTablesSQLafter normalizing comments, layout, and the'app'literal.Before / after
🤖 Created with Amp (Claude Opus 4.6).