From e0ed8a03c486501e2046b43414b4d75fe5a06803 Mon Sep 17 00:00:00 2001
From: Joel Jacobson <joel@compiler.org>
Date: Sun, 17 May 2026 11:11:08 +0200
Subject: [PATCH v6 2/3] Implement FOR KEY join support

Mathematically, CROSS JOINs are a cartesian product, INNER JOINs a
subset of it, and OUTER JOINs potentially null-extend that subset.
In practice, however, most joins look up additional information along
a foreign key rather than resembling a cartesian product.

Today a written query does not convey whether a particular JOIN simply
enriches the referencing side, filters it, or fans it out:

-- wrong: both sums silently inflated by the fan-out
SELECT o.id, SUM(oi.amount), SUM(p.amount)
FROM orders o
LEFT JOIN order_items oi ON oi.order_id = o.id
LEFT JOIN payments    p  ON p.order_id  = o.id
GROUP BY o.id;

We propose a new JOIN syntax that makes it easy to determine locally
that the immediate join result, before any further steps, just enriches
the referencing side with information from the referenced side, with
null-extension for OUTER JOINs. It conveys the author's intent, makes
the referencing side visually clear, and is enforced at compile time
against the schema. If we can't prove it, the user gets a compile-time
error.

Under `FOR KEY` the same query will not compile:

SELECT o.id, SUM(oi.amount), SUM(p.amount)
FROM orders o
LEFT JOIN order_items oi FOR KEY (order_id) -> o (id)
LEFT JOIN payments    p  FOR KEY (order_id) -> o (id)
GROUP BY o.id;

ERROR:  key join from referencing relation p to referenced relation o cannot be proven
LINE 7: LEFT JOIN payments    AS p  FOR KEY (order_id) -> o (id)
                                    ^
DETAIL:  Referenced columns o (id) are not proven unique. A preceding join may duplicate rows from referenced relation o.

Authors: Joel Jacobson, Andreas Karlsson, Arne Roland
---
 doc/src/sgml/catalogs.sgml                    |   14 +
 doc/src/sgml/queries.sgml                     |   58 +-
 doc/src/sgml/ref/select.sgml                  |  106 +-
 src/backend/catalog/dependency.c              |   78 +
 src/backend/catalog/pg_proc.c                 |   21 +
 src/backend/commands/Makefile                 |    1 +
 src/backend/commands/functioncmds.c           |   11 +
 src/backend/commands/keyjoincmds.c            |  406 +
 src/backend/commands/meson.build              |    1 +
 src/backend/commands/policy.c                 |    4 +
 src/backend/commands/tablecmds.c              |   36 +
 src/backend/commands/view.c                   |    2 +
 src/backend/nodes/nodeFuncs.c                 |   75 +-
 src/backend/parser/Makefile                   |    1 +
 src/backend/parser/gram.y                     |   42 +-
 src/backend/parser/meson.build                |    1 +
 src/backend/parser/parse_clause.c             |   53 +-
 src/backend/parser/parse_cte.c                |    1 +
 src/backend/parser/parse_key_join.c           | 5063 +++++++++++++
 src/backend/rewrite/rewriteDefine.c           |    2 +
 src/backend/rewrite/rewriteManip.c            |   38 +-
 src/backend/utils/adt/ruleutils.c             |   73 +-
 src/include/catalog/dependency.h              |    4 +
 src/include/commands/keyjoin.h                |   23 +
 src/include/nodes/parsenodes.h                |  235 +
 src/include/nodes/primnodes.h                 |   23 +-
 src/include/parser/parse_key_join.h           |   27 +
 src/pl/plpgsql/src/pl_gram.y                  |    1 +
 .../expected/key-join-function-race.out       |   39 +
 .../expected/key-join-lazy-facts-lock.out     |   11 +
 ...y-join-plain-function-alter-owner-lock.out |   33 +
 ...key-join-plain-function-dependent-lock.out |    7 +
 ...ey-join-plain-function-ownercheck-lock.out |   36 +
 .../key-join-plain-function-prelock.out       |    7 +
 ...key-join-plain-relation-dependent-lock.out |   13 +
 .../expected/key-join-plain-view-rls-lock.out |   13 +
 .../key-join-policy-function-deadlock.out     |   25 +
 .../key-join-recursive-revalidation-lock.out  |   26 +
 .../key-join-sqlbody-function-prelock.out     |    8 +
 .../key-join-stale-policy-dependency.out      |   18 +
 .../key-join-stale-rule-dependency.out        |   18 +
 .../key-join-stale-table-rule-dependency.out  |   17 +
 src/test/isolation/isolation_schedule         |   14 +
 .../specs/key-join-function-race.spec         |   78 +
 .../specs/key-join-lazy-facts-lock.spec       |   34 +
 ...-join-plain-function-alter-owner-lock.spec |   64 +
 ...ey-join-plain-function-dependent-lock.spec |   30 +
 ...y-join-plain-function-ownercheck-lock.spec |   54 +
 .../key-join-plain-function-prelock.spec      |   30 +
 ...ey-join-plain-relation-dependent-lock.spec |   42 +
 .../specs/key-join-plain-view-rls-lock.spec   |   41 +
 .../key-join-policy-function-deadlock.spec    |   68 +
 .../key-join-recursive-revalidation-lock.spec |   79 +
 .../key-join-sqlbody-function-prelock.spec    |   55 +
 .../key-join-stale-policy-dependency.spec     |   65 +
 .../specs/key-join-stale-rule-dependency.spec |   58 +
 .../key-join-stale-table-rule-dependency.spec |   61 +
 .../injection_points/expected/key_join.out    |   59 +
 .../key_join_proof_race_constraints.out       |   66 +
 ...oin_proof_race_filter_function_prelock.out |   20 +
 ...oin_proof_race_filter_operator_prelock.out |   23 +
 .../key_join_proof_race_function_prelock.out  |   26 +
 .../expected/key_join_proof_race_indexes.out  |   21 +
 .../key_join_proof_race_operator_cascade.out  |   25 +
 .../key_join_proof_race_operator_prelock.out  |   25 +
 .../key_join_proof_race_producer_view.out     |   23 +
 .../key_join_proof_race_record_proc_dep.out   |   28 +
 .../key_join_proof_race_relation_state.out    |   43 +
 .../key_join_stale_function_dependency.out    |   16 +
 .../injection_points/injection_points.c       |   19 +
 src/test/modules/injection_points/meson.build |   12 +
 .../key_join_proof_race_constraints.spec      |   81 +
 ...in_proof_race_filter_function_prelock.spec |   71 +
 ...in_proof_race_filter_operator_prelock.spec |   87 +
 .../key_join_proof_race_function_prelock.spec |  101 +
 .../specs/key_join_proof_race_indexes.spec    |   86 +
 .../key_join_proof_race_operator_cascade.spec |   93 +
 .../key_join_proof_race_operator_prelock.spec |  101 +
 .../key_join_proof_race_producer_view.spec    |   73 +
 .../key_join_proof_race_record_proc_dep.spec  |   78 +
 .../key_join_proof_race_relation_state.spec   |   73 +
 .../key_join_stale_function_dependency.spec   |   75 +
 .../modules/injection_points/sql/key_join.sql |   37 +
 .../regress/expected/collate.icu.utf8.out     |   15 +
 src/test/regress/expected/key_join.out        | 6603 +++++++++++++++++
 src/test/regress/expected/key_join_icu.out    |  105 +
 src/test/regress/expected/key_join_icu_1.out  |   17 +
 .../expected/key_join_matched_filter.out      | 1091 +++
 src/test/regress/expected/key_join_mcdc.out   |  745 ++
 .../regress/expected/key_join_overview.out    |  978 +++
 src/test/regress/parallel_schedule            |    5 +
 src/test/regress/sql/collate.icu.utf8.sql     |   12 +
 src/test/regress/sql/key_join.sql             | 5668 ++++++++++++++
 src/test/regress/sql/key_join_icu.sql         |  113 +
 .../regress/sql/key_join_matched_filter.sql   |  999 +++
 src/test/regress/sql/key_join_mcdc.sql        |  758 ++
 src/test/regress/sql/key_join_overview.sql    |  764 ++
 src/tools/pgindent/typedefs.list              |   16 +
 98 files changed, 26672 insertions(+), 23 deletions(-)
 create mode 100644 src/backend/commands/keyjoincmds.c
 create mode 100644 src/backend/parser/parse_key_join.c
 create mode 100644 src/include/commands/keyjoin.h
 create mode 100644 src/include/parser/parse_key_join.h
 create mode 100644 src/test/isolation/expected/key-join-function-race.out
 create mode 100644 src/test/isolation/expected/key-join-lazy-facts-lock.out
 create mode 100644 src/test/isolation/expected/key-join-plain-function-alter-owner-lock.out
 create mode 100644 src/test/isolation/expected/key-join-plain-function-dependent-lock.out
 create mode 100644 src/test/isolation/expected/key-join-plain-function-ownercheck-lock.out
 create mode 100644 src/test/isolation/expected/key-join-plain-function-prelock.out
 create mode 100644 src/test/isolation/expected/key-join-plain-relation-dependent-lock.out
 create mode 100644 src/test/isolation/expected/key-join-plain-view-rls-lock.out
 create mode 100644 src/test/isolation/expected/key-join-policy-function-deadlock.out
 create mode 100644 src/test/isolation/expected/key-join-recursive-revalidation-lock.out
 create mode 100644 src/test/isolation/expected/key-join-sqlbody-function-prelock.out
 create mode 100644 src/test/isolation/expected/key-join-stale-policy-dependency.out
 create mode 100644 src/test/isolation/expected/key-join-stale-rule-dependency.out
 create mode 100644 src/test/isolation/expected/key-join-stale-table-rule-dependency.out
 create mode 100644 src/test/isolation/specs/key-join-function-race.spec
 create mode 100644 src/test/isolation/specs/key-join-lazy-facts-lock.spec
 create mode 100644 src/test/isolation/specs/key-join-plain-function-alter-owner-lock.spec
 create mode 100644 src/test/isolation/specs/key-join-plain-function-dependent-lock.spec
 create mode 100644 src/test/isolation/specs/key-join-plain-function-ownercheck-lock.spec
 create mode 100644 src/test/isolation/specs/key-join-plain-function-prelock.spec
 create mode 100644 src/test/isolation/specs/key-join-plain-relation-dependent-lock.spec
 create mode 100644 src/test/isolation/specs/key-join-plain-view-rls-lock.spec
 create mode 100644 src/test/isolation/specs/key-join-policy-function-deadlock.spec
 create mode 100644 src/test/isolation/specs/key-join-recursive-revalidation-lock.spec
 create mode 100644 src/test/isolation/specs/key-join-sqlbody-function-prelock.spec
 create mode 100644 src/test/isolation/specs/key-join-stale-policy-dependency.spec
 create mode 100644 src/test/isolation/specs/key-join-stale-rule-dependency.spec
 create mode 100644 src/test/isolation/specs/key-join-stale-table-rule-dependency.spec
 create mode 100644 src/test/modules/injection_points/expected/key_join.out
 create mode 100644 src/test/modules/injection_points/expected/key_join_proof_race_constraints.out
 create mode 100644 src/test/modules/injection_points/expected/key_join_proof_race_filter_function_prelock.out
 create mode 100644 src/test/modules/injection_points/expected/key_join_proof_race_filter_operator_prelock.out
 create mode 100644 src/test/modules/injection_points/expected/key_join_proof_race_function_prelock.out
 create mode 100644 src/test/modules/injection_points/expected/key_join_proof_race_indexes.out
 create mode 100644 src/test/modules/injection_points/expected/key_join_proof_race_operator_cascade.out
 create mode 100644 src/test/modules/injection_points/expected/key_join_proof_race_operator_prelock.out
 create mode 100644 src/test/modules/injection_points/expected/key_join_proof_race_producer_view.out
 create mode 100644 src/test/modules/injection_points/expected/key_join_proof_race_record_proc_dep.out
 create mode 100644 src/test/modules/injection_points/expected/key_join_proof_race_relation_state.out
 create mode 100644 src/test/modules/injection_points/expected/key_join_stale_function_dependency.out
 create mode 100644 src/test/modules/injection_points/specs/key_join_proof_race_constraints.spec
 create mode 100644 src/test/modules/injection_points/specs/key_join_proof_race_filter_function_prelock.spec
 create mode 100644 src/test/modules/injection_points/specs/key_join_proof_race_filter_operator_prelock.spec
 create mode 100644 src/test/modules/injection_points/specs/key_join_proof_race_function_prelock.spec
 create mode 100644 src/test/modules/injection_points/specs/key_join_proof_race_indexes.spec
 create mode 100644 src/test/modules/injection_points/specs/key_join_proof_race_operator_cascade.spec
 create mode 100644 src/test/modules/injection_points/specs/key_join_proof_race_operator_prelock.spec
 create mode 100644 src/test/modules/injection_points/specs/key_join_proof_race_producer_view.spec
 create mode 100644 src/test/modules/injection_points/specs/key_join_proof_race_record_proc_dep.spec
 create mode 100644 src/test/modules/injection_points/specs/key_join_proof_race_relation_state.spec
 create mode 100644 src/test/modules/injection_points/specs/key_join_stale_function_dependency.spec
 create mode 100644 src/test/modules/injection_points/sql/key_join.sql
 create mode 100644 src/test/regress/expected/key_join.out
 create mode 100644 src/test/regress/expected/key_join_icu.out
 create mode 100644 src/test/regress/expected/key_join_icu_1.out
 create mode 100644 src/test/regress/expected/key_join_matched_filter.out
 create mode 100644 src/test/regress/expected/key_join_mcdc.out
 create mode 100644 src/test/regress/expected/key_join_overview.out
 create mode 100644 src/test/regress/sql/key_join.sql
 create mode 100644 src/test/regress/sql/key_join_icu.sql
 create mode 100644 src/test/regress/sql/key_join_matched_filter.sql
 create mode 100644 src/test/regress/sql/key_join_mcdc.sql
 create mode 100644 src/test/regress/sql/key_join_overview.sql

diff --git a/doc/src/sgml/catalogs.sgml b/doc/src/sgml/catalogs.sgml
index 4b474c13917..f71930b11e4 100644
--- a/doc/src/sgml/catalogs.sgml
+++ b/doc/src/sgml/catalogs.sgml
@@ -3555,6 +3555,20 @@ SCRAM-SHA-256$<replaceable>&lt;iteration count&gt;</replaceable>:<replaceable>&l
      </listitem>
     </varlistentry>
 
+    <varlistentry>
+     <term><symbol>DEPENDENCY_KEYJOIN</symbol> (<literal>k</literal>)</term>
+     <listitem>
+      <para>
+       A normal drop dependency that also identifies an object as part of a
+       stored <literal>FOR KEY</literal> join proof.  Revalidation uses this
+       dependency type to find stored proofs affected by changes to
+       constraints, relations, operators, and functions without following
+       unrelated ordinary expression dependencies.  Drop behavior is otherwise
+       the same as <literal>DEPENDENCY_NORMAL</literal>.
+      </para>
+     </listitem>
+    </varlistentry>
+
     <varlistentry>
      <term><symbol>DEPENDENCY_AUTO</symbol> (<literal>a</literal>)</term>
      <listitem>
diff --git a/doc/src/sgml/queries.sgml b/doc/src/sgml/queries.sgml
index ec4ca01cd16..b67ea600435 100644
--- a/doc/src/sgml/queries.sgml
+++ b/doc/src/sgml/queries.sgml
@@ -249,6 +249,9 @@ FROM <replaceable>table_reference</replaceable> <optional>, <replaceable>table_r
 <synopsis>
 <replaceable>T1</replaceable> { <optional>INNER</optional> | { LEFT | RIGHT | FULL } <optional>OUTER</optional> } JOIN <replaceable>T2</replaceable> ON <replaceable>boolean_expression</replaceable>
 <replaceable>T1</replaceable> { <optional>INNER</optional> | { LEFT | RIGHT | FULL } <optional>OUTER</optional> } JOIN <replaceable>T2</replaceable> USING ( <replaceable>join column list</replaceable> )
+<replaceable>T1</replaceable> { <optional>INNER</optional> | { LEFT | RIGHT | FULL } <optional>OUTER</optional> } JOIN <replaceable>T2</replaceable>
+    FOR KEY ( <replaceable>right column list</replaceable> ) { -&gt; | &lt;- } <replaceable>T1 relation</replaceable> ( <replaceable>left column list</replaceable> )
+    <optional> FILTER (WHERE <replaceable>boolean_expression</replaceable>) </optional>
 <replaceable>T1</replaceable> NATURAL { <optional>INNER</optional> | { LEFT | RIGHT | FULL } <optional>OUTER</optional> } JOIN <replaceable>T2</replaceable>
 </synopsis>
 
@@ -262,10 +265,11 @@ FROM <replaceable>table_reference</replaceable> <optional>, <replaceable>table_r
 
        <para>
         The <firstterm>join condition</firstterm> is specified in the
-        <literal>ON</literal> or <literal>USING</literal> clause, or implicitly by
-        the word <literal>NATURAL</literal>.  The join condition determines
-        which rows from the two source tables are considered to
-        <quote>match</quote>, as explained in detail below.
+        <literal>ON</literal>, <literal>USING</literal>, or
+        <literal>FOR KEY</literal> clause, or implicitly by the word
+        <literal>NATURAL</literal>.  The join condition determines which rows
+        from the two source tables are considered to <quote>match</quote>, as
+        explained in detail below.
        </para>
 
        <para>
@@ -378,6 +382,52 @@ FROM <replaceable>table_reference</replaceable> <optional>, <replaceable>table_r
         followed by any remaining columns from <replaceable>T2</replaceable>.
        </para>
 
+       <para>
+        A <literal>FOR KEY</literal> clause is used when the intended join is a
+        foreign-key traversal: it enriches rows from the referencing side with
+        the uniquely matching row from the referenced side.  It generates
+        equality comparisons between a referencing column list and a referenced
+        column list, and the query is rejected if the relationship cannot be
+        proven from usable constraints and query facts.  Unlike
+        <literal>USING</literal>, <literal>FOR KEY</literal> does not remove
+        duplicate columns from the join output; its output columns are the
+        same as for <literal>JOIN ON</literal>.
+       </para>
+
+       <para>
+        In a <literal>FOR KEY</literal> clause, the list immediately after
+        <literal>FOR KEY</literal> names columns of <replaceable>T2</replaceable>.
+        The relation name after the arrow must be a relation visible from
+        <replaceable>T1</replaceable>.  The arrow always points from the
+        referencing columns toward the referenced columns.  With
+        <literal>-&gt;</literal>, the <replaceable>T2</replaceable> columns are
+        the referencing foreign-key columns and the named
+        <replaceable>T1</replaceable> columns are the referenced key columns.
+        With <literal>&lt;-</literal>, the roles are reversed.
+       </para>
+
+       <para>
+        PostgreSQL checks a <literal>FOR KEY</literal> clause at the point
+        where the join is evaluated.  The referenced columns must identify at
+        most one matching referenced row, every all-non-null referencing key
+        value present in the referencing operand at this join point must have
+        a matching referenced value in the referenced operand, and the
+        referencing key values must be known non-null or rows from the
+        referencing side must be preserved by the join type.
+       </para>
+
+       <para>
+        For example, if <structfield>orders.customer_id</structfield> is a
+        non-null foreign key referencing <structfield>customers.id</structfield>,
+        the following join asks for each order to be enriched with its
+        customer:
+<programlisting>
+SELECT o.id, c.name
+FROM orders AS o
+JOIN customers AS c FOR KEY (id) &lt;- o (customer_id);
+</programlisting>
+       </para>
+
        <para>
         <indexterm>
          <primary>join</primary>
diff --git a/doc/src/sgml/ref/select.sgml b/doc/src/sgml/ref/select.sgml
index 09b6ce809bb..fa58b2f602c 100644
--- a/doc/src/sgml/ref/select.sgml
+++ b/doc/src/sgml/ref/select.sgml
@@ -60,7 +60,11 @@ SELECT [ ALL | DISTINCT [ ON ( <replaceable class="parameter">expression</replac
     [ LATERAL ] ROWS FROM( <replaceable class="parameter">function_name</replaceable> ( [ <replaceable class="parameter">argument</replaceable> [, ...] ] ) [ AS ( <replaceable class="parameter">column_definition</replaceable> [, ...] ) ] [, ...] )
                 [ WITH ORDINALITY ] [ [ AS ] <replaceable class="parameter">alias</replaceable> [ ( <replaceable class="parameter">column_alias</replaceable> [, ...] ) ] ]
     GRAPH_TABLE ( <replaceable class="parameter">graph_name</replaceable> MATCH <replaceable class="parameter">graph_pattern</replaceable> COLUMNS ( { <replaceable class="parameter">expression</replaceable> [ AS <replaceable class="parameter">name</replaceable> ] } [, ...] ) ) [ [ AS ] <replaceable class="parameter">alias</replaceable> [ ( <replaceable class="parameter">column_alias</replaceable> [, ...] ) ] ]
-    <replaceable class="parameter">from_item</replaceable> <replaceable class="parameter">join_type</replaceable> <replaceable class="parameter">from_item</replaceable> { ON <replaceable class="parameter">join_condition</replaceable> | USING ( <replaceable class="parameter">join_column</replaceable> [, ...] ) [ AS <replaceable class="parameter">join_using_alias</replaceable> ] }
+    <replaceable class="parameter">from_item</replaceable> <replaceable class="parameter">join_type</replaceable> <replaceable class="parameter">from_item</replaceable> ON <replaceable class="parameter">join_condition</replaceable>
+    <replaceable class="parameter">from_item</replaceable> <replaceable class="parameter">join_type</replaceable> <replaceable class="parameter">from_item</replaceable> USING ( <replaceable class="parameter">join_column</replaceable> [, ...] ) [ AS <replaceable class="parameter">join_using_alias</replaceable> ]
+    <replaceable class="parameter">from_item</replaceable> <replaceable class="parameter">join_type</replaceable> <replaceable class="parameter">from_item</replaceable>
+        FOR KEY ( <replaceable class="parameter">right_join_column</replaceable> [, ...] ) { -&gt; | &lt;- } <replaceable class="parameter">left_from_reference</replaceable> ( <replaceable class="parameter">left_join_column</replaceable> [, ...] )
+        [ FILTER (WHERE <replaceable class="parameter">join_filter</replaceable>) ]
     <replaceable class="parameter">from_item</replaceable> NATURAL <replaceable class="parameter">join_type</replaceable> <replaceable class="parameter">from_item</replaceable>
     <replaceable class="parameter">from_item</replaceable> CROSS JOIN <replaceable class="parameter">from_item</replaceable>
 
@@ -656,6 +660,8 @@ TABLE [ ONLY ] <replaceable class="parameter">table_name</replaceable> [ * ]
         class="parameter">join_condition</replaceable></literal>,
         <literal>USING (<replaceable
         class="parameter">join_column</replaceable> [, ...])</literal>,
+        <literal>FOR KEY (<replaceable
+        class="parameter">join_column</replaceable> [, ...])</literal>,
         or <literal>NATURAL</literal>.  See below for the meaning.
        </para>
 
@@ -669,7 +675,8 @@ TABLE [ ONLY ] <replaceable class="parameter">table_name</replaceable> [ * ]
         tightly than the commas separating <literal>FROM</literal>-list items.
         All the <literal>JOIN</literal> options are just a notational
         convenience, since they do nothing you couldn't do with plain
-        <literal>FROM</literal> and <literal>WHERE</literal>.
+        <literal>FROM</literal> and <literal>WHERE</literal>, except that
+        <literal>FOR KEY</literal> also performs a proof check.
        </para>
 
        <para><literal>LEFT OUTER JOIN</literal> returns all rows in the qualified
@@ -737,6 +744,101 @@ TABLE [ ONLY ] <replaceable class="parameter">table_name</replaceable> [ * ]
       </listitem>
      </varlistentry>
 
+     <varlistentry>
+      <term><literal>FOR KEY ( <replaceable class="parameter">right_join_column</replaceable> [, ...] ) { -&gt; | &lt;- } <replaceable class="parameter">left_from_reference</replaceable> ( <replaceable class="parameter">left_join_column</replaceable> [, ...] ) [ FILTER (WHERE <replaceable class="parameter">join_filter</replaceable>) ]</literal></term>
+      <listitem>
+       <para>
+        A <literal>FOR KEY</literal> clause requests an equality join that can
+        be proven to follow a usable foreign-key relationship.  It declares a
+        foreign-key traversal from the referencing side to the referenced side,
+        enriching referencing rows with their uniquely matching referenced
+        rows.  The equality comparisons for the join are generated
+        automatically.  If the key relationship cannot be proven during parse
+        analysis, the query is rejected.
+       </para>
+
+       <para>
+        The column list immediately after <literal>FOR KEY</literal> names
+        columns exposed by the right-hand <replaceable
+        class="parameter">from_item</replaceable>.  The <replaceable
+        class="parameter">left_from_reference</replaceable> after the arrow
+        must name a relation visible in the left-hand <replaceable
+        class="parameter">from_item</replaceable>, and its column list names
+        columns exposed by that relation.  With <literal>-&gt;</literal>,
+        the right-hand columns are the referencing columns and the
+        left-hand columns are the referenced columns.  With
+        <literal>&lt;-</literal>, the right-hand columns are the referenced
+        columns and the left-hand columns are the referencing columns.
+        The two column lists must have the same number of columns.
+       </para>
+
+       <para>
+        PostgreSQL checks the proof at the join point, using facts visible
+        from the two <replaceable class="parameter">from_item</replaceable>
+        inputs.  A valid <literal>FOR KEY</literal> join must satisfy these
+        requirements:
+       </para>
+
+       <itemizedlist>
+        <listitem>
+         <para>
+          The referenced columns must be unique in the referenced input.  This
+          prevents one referencing row from matching more than one referenced
+          row.
+         </para>
+        </listitem>
+
+        <listitem>
+         <para>
+          Every all-non-null referencing key value present in the referencing
+          operand at this join point must have a matching referenced value in
+          the referenced operand.  This requires a matching usable foreign-key
+          constraint and, for derived referenced inputs, enough row coverage to
+          show that needed referenced rows have not been filtered away.
+         </para>
+        </listitem>
+
+        <listitem>
+         <para>
+          The referencing columns must be known non-null, or the join type must
+          preserve the referencing side.  Otherwise an inner join could
+          silently drop referencing rows whose key contains nulls.
+         </para>
+        </listitem>
+       </itemizedlist>
+
+       <para>
+        Base-table proof uses enforced, validated, nondeferrable foreign-key
+        constraints and usable unique keys whose key identities match.  Derived
+        tables, views, common table expressions, and previous key joins can
+        participate when these facts remain visible through their query
+        definitions.  <literal>FOR KEY</literal> does not suppress duplicate
+        output columns; its output column behavior is like
+        <literal>JOIN ON</literal>.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term><literal>FILTER (WHERE <replaceable class="parameter">join_filter</replaceable>)</literal></term>
+      <listitem>
+       <para>
+        A <literal>FILTER</literal> clause can be written after a
+        <literal>FOR KEY</literal> join condition to add a Boolean condition
+        to the join.  The filter can reference columns from both join inputs
+        and has the same match-determining semantics as a condition written
+        in an <literal>ON</literal> clause.  For outer joins, rows preserved
+        by the join type are still emitted with nulls for the nonmatching or
+        filtered side.
+       </para>
+
+       <para>
+        This join-local filter is applied after the key-join relationship has
+        been proven.
+       </para>
+      </listitem>
+     </varlistentry>
+
      <varlistentry>
       <term><literal>NATURAL</literal></term>
       <listitem>
diff --git a/src/backend/catalog/dependency.c b/src/backend/catalog/dependency.c
index fdb8e67e1f5..6408bc21a02 100644
--- a/src/backend/catalog/dependency.c
+++ b/src/backend/catalog/dependency.c
@@ -165,6 +165,7 @@ static void deleteOneObject(const ObjectAddress *object,
 static void doDeletion(const ObjectAddress *object, int flags);
 static bool find_expr_references_walker(Node *node,
 										find_expr_references_context *context);
+static bool find_keyjoin_proof_dependencies_walker(Node *node, void *context);
 static void process_function_rte_ref(RangeTblEntry *rte, AttrNumber attnum,
 									 find_expr_references_context *context);
 static void eliminate_duplicate_dependencies(ObjectAddresses *addrs);
@@ -616,6 +617,7 @@ findDependentObjects(const ObjectAddress *object,
 		switch (foundDep->deptype)
 		{
 			case DEPENDENCY_NORMAL:
+			case DEPENDENCY_KEYJOIN:
 			case DEPENDENCY_AUTO:
 			case DEPENDENCY_AUTO_EXTENSION:
 				/* no problem */
@@ -938,6 +940,7 @@ findDependentObjects(const ObjectAddress *object,
 		switch (foundDep->deptype)
 		{
 			case DEPENDENCY_NORMAL:
+			case DEPENDENCY_KEYJOIN:
 				subflags = DEPFLAG_NORMAL;
 				break;
 			case DEPENDENCY_AUTO:
@@ -1643,6 +1646,31 @@ recordDependencyOnExpr(const ObjectAddress *depender,
 	free_object_addresses(addrs);
 }
 
+/*
+ * recordDependencyOnKeyJoinProofs - record FOR KEY proof dependencies
+ *
+ * Stored FOR KEY joins carry proof dependencies in KeyJoinNode nodes.  These
+ * are not ordinary expression references: revalidation uses them as a proof
+ * graph, so record them with DEPENDENCY_KEYJOIN after the normal expression
+ * dependency pass has run.
+ */
+void
+recordDependencyOnKeyJoinProofs(const ObjectAddress *depender, Node *expr)
+{
+	ObjectAddresses *addrs;
+
+	addrs = new_object_addresses();
+
+	(void) find_keyjoin_proof_dependencies_walker(expr, addrs);
+
+	eliminate_duplicate_dependencies(addrs);
+	recordMultipleDependencies(depender,
+							   addrs->refs, addrs->numrefs,
+							   DEPENDENCY_KEYJOIN);
+
+	free_object_addresses(addrs);
+}
+
 /*
  * collectDependenciesOfExpr - collect expression dependencies
  *
@@ -2443,6 +2471,56 @@ find_expr_references_walker(Node *node,
 								  context);
 }
 
+/*
+ * find_keyjoin_proof_dependencies_walker
+ *
+ * Collect only the proof graph edges embedded in stored KeyJoinNodes.  The
+ * ordinary expression dependency walker deliberately skips these nodes.
+ */
+static bool
+find_keyjoin_proof_dependencies_walker(Node *node, void *context)
+{
+	ObjectAddresses *addrs = (ObjectAddresses *) context;
+
+	if (node == NULL)
+		return false;
+
+	if (IsA(node, KeyJoinNode))
+	{
+		KeyJoinNode *key_join = castNode(KeyJoinNode, node);
+
+		foreach_node(KeyJoinProofDependency, dep,
+					 key_join->proofDependencies)
+			add_object_address(dep->classId, dep->objectId, 0, addrs);
+
+		return false;
+	}
+
+	if (IsA(node, JoinExpr))
+	{
+		JoinExpr   *join = castNode(JoinExpr, node);
+
+		(void) find_keyjoin_proof_dependencies_walker(join->keyJoin, addrs);
+		(void) find_keyjoin_proof_dependencies_walker(join->larg, addrs);
+		(void) find_keyjoin_proof_dependencies_walker(join->rarg, addrs);
+		(void) find_keyjoin_proof_dependencies_walker(join->quals, addrs);
+		(void) find_keyjoin_proof_dependencies_walker(join->joinFilter, addrs);
+		return false;
+	}
+
+	if (IsA(node, Query))
+	{
+		(void) query_tree_walker(castNode(Query, node),
+								 find_keyjoin_proof_dependencies_walker,
+								 addrs, 0);
+		return false;
+	}
+
+	(void) expression_tree_walker(node, find_keyjoin_proof_dependencies_walker,
+								  addrs);
+	return false;
+}
+
 /*
  * find_expr_references_walker subroutine: handle a Var reference
  * to an RTE_FUNCTION RTE
diff --git a/src/backend/catalog/pg_proc.c b/src/backend/catalog/pg_proc.c
index d5cc7589091..9424229c78a 100644
--- a/src/backend/catalog/pg_proc.c
+++ b/src/backend/catalog/pg_proc.c
@@ -27,6 +27,7 @@
 #include "catalog/pg_proc.h"
 #include "catalog/pg_transform.h"
 #include "catalog/pg_type.h"
+#include "commands/keyjoin.h"
 #include "executor/functions.h"
 #include "funcapi.h"
 #include "mb/pg_wchar.h"
@@ -717,6 +718,12 @@ ProcedureCreate(const char *procedureName,
 
 	free_object_addresses(addrs);
 
+	/* Record FOR KEY proof dependencies separately from ordinary references. */
+	if (languageObjectId == SQLlanguageId && prosqlbody)
+		recordDependencyOnKeyJoinProofs(&myself, prosqlbody);
+	if (parameterDefaults)
+		recordDependencyOnKeyJoinProofs(&myself, (Node *) parameterDefaults);
+
 	/* dependency on owner */
 	if (!is_update)
 		recordDependencyOnOwner(ProcedureRelationId, retval, proowner);
@@ -778,6 +785,20 @@ ProcedureCreate(const char *procedureName,
 	if (!is_update)
 		pgstat_create_function(retval);
 
+	/*
+	 * For CREATE OR REPLACE FUNCTION, revalidate stored key-join proofs that
+	 * captured this function in a matched-filter conjunct.  The function's
+	 * body, volatility, or strictness may all have changed; if a stored proof
+	 * no longer holds, revalidation raises an error and aborts the DDL.
+	 * Plain CREATE FUNCTION can't have any dependents yet, so revalidation is
+	 * needed only for the replace path.
+	 */
+	if (is_update)
+	{
+		CommandCounterIncrement();
+		RevalidateDependentKeyJoinObjectsOnProcedure(retval);
+	}
+
 	return myself;
 }
 
diff --git a/src/backend/commands/Makefile b/src/backend/commands/Makefile
index 5b9d084977e..fcebd7f2960 100644
--- a/src/backend/commands/Makefile
+++ b/src/backend/commands/Makefile
@@ -40,6 +40,7 @@ OBJS = \
 	foreigncmds.o \
 	functioncmds.o \
 	indexcmds.o \
+	keyjoincmds.o \
 	lockcmds.o \
 	matview.o \
 	opclasscmds.o \
diff --git a/src/backend/commands/functioncmds.c b/src/backend/commands/functioncmds.c
index b41a8a72ea6..bb85f3faabc 100644
--- a/src/backend/commands/functioncmds.c
+++ b/src/backend/commands/functioncmds.c
@@ -48,6 +48,7 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "commands/extension.h"
+#include "commands/keyjoin.h"
 #include "commands/proclang.h"
 #include "executor/executor.h"
 #include "executor/functions.h"
@@ -1560,6 +1561,16 @@ AlterFunction(ParseState *pstate, AlterFunctionStmt *stmt)
 	table_close(rel, NoLock);
 	heap_freetuple(tup);
 
+	/*
+	 * Revalidate stored key-join proofs that captured this function in a
+	 * matched-filter conjunct.  Proof volatility and strictness assumptions
+	 * were decided at parse time; a later relevant function change can break
+	 * those contracts, so conservatively re-run validation after ALTER
+	 * FUNCTION and abort this DDL if the proof no longer holds.
+	 */
+	CommandCounterIncrement();
+	RevalidateDependentKeyJoinObjectsOnProcedure(funcOid);
+
 	return address;
 }
 
diff --git a/src/backend/commands/keyjoincmds.c b/src/backend/commands/keyjoincmds.c
new file mode 100644
index 00000000000..03c2b779895
--- /dev/null
+++ b/src/backend/commands/keyjoincmds.c
@@ -0,0 +1,406 @@
+/*-------------------------------------------------------------------------
+ *
+ * keyjoincmds.c
+ *	  Commands for manipulating stored key-join proofs.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ *
+ * IDENTIFICATION
+ *	  src/backend/commands/keyjoincmds.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "access/genam.h"
+#include "access/htup_details.h"
+#include "access/relation.h"
+#include "access/skey.h"
+#include "access/table.h"
+#include "catalog/dependency.h"
+#include "catalog/indexing.h"
+#include "catalog/objectaddress.h"
+#include "catalog/pg_class.h"
+#include "catalog/pg_constraint.h"
+#include "catalog/pg_depend.h"
+#include "catalog/pg_policy.h"
+#include "catalog/pg_proc.h"
+#include "catalog/pg_rewrite.h"
+#include "common/int.h"
+#include "commands/keyjoin.h"
+#include "nodes/nodeFuncs.h"
+#include "nodes/parsenodes.h"
+#include "parser/parse_key_join.h"
+#include "storage/lmgr.h"
+#include "utils/builtins.h"
+#include "utils/fmgroids.h"
+#include "utils/injection_point.h"
+#include "utils/rel.h"
+#include "utils/syscache.h"
+
+static Oid	get_rule_event_relation(Oid ruleOid);
+static List *find_dependent_key_join_objects(Oid refclassid, Oid refobjid);
+static void revalidate_dependent_key_join_objects(List *objects);
+static void revalidate_dependent_key_join_object(const ObjectAddress *object);
+static bool object_address_list_member(List *objects, Oid classId,
+									   Oid objectId);
+static int	object_address_list_cmp(const ListCell *a, const ListCell *b);
+static ObjectAddress *make_object_address(Oid classId, Oid objectId);
+static void revalidate_dependent_key_join_relation(Oid relationOid);
+static void revalidate_dependent_key_join_function(Oid procOid);
+static void revalidate_dependent_key_join_policy(Oid policy_id);
+static void revalidate_stored_key_join_node(Node *stored);
+
+static Oid
+get_rule_event_relation(Oid ruleOid)
+{
+	Relation	rewriteRel;
+	ScanKeyData key[1];
+	SysScanDesc scan;
+	HeapTuple	tup;
+	Oid			result = InvalidOid;
+
+	rewriteRel = table_open(RewriteRelationId, AccessShareLock);
+	ScanKeyInit(&key[0],
+				Anum_pg_rewrite_oid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(ruleOid));
+	scan = systable_beginscan(rewriteRel, RewriteOidIndexId, true,
+							  NULL, 1, key);
+	tup = systable_getnext(scan);
+	if (!HeapTupleIsValid(tup))
+	{
+		/*
+		 * The dependent rule can be dropped after pg_depend is scanned but
+		 * before this lookup acquires pg_rewrite.  Then the owning object is
+		 * gone too, so there is no stored proof left to revalidate.
+		 */
+		systable_endscan(scan);
+		table_close(rewriteRel, AccessShareLock);
+		return InvalidOid;
+	}
+	result = ((Form_pg_rewrite) GETSTRUCT(tup))->ev_class;
+	Assert(OidIsValid(result));
+	systable_endscan(scan);
+	table_close(rewriteRel, AccessShareLock);
+
+	return result;
+}
+
+static List *
+find_dependent_key_join_objects(Oid refclassid, Oid refobjid)
+{
+	Relation	depRel;
+	ScanKeyData key[2];
+	SysScanDesc scan;
+	HeapTuple	tup;
+	List	   *result = NIL;
+
+	depRel = table_open(DependRelationId, AccessShareLock);
+	ScanKeyInit(&key[0],
+				Anum_pg_depend_refclassid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(refclassid));
+	ScanKeyInit(&key[1],
+				Anum_pg_depend_refobjid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(refobjid));
+	scan = systable_beginscan(depRel, DependReferenceIndexId, true,
+							  NULL, 2, key);
+
+	while (HeapTupleIsValid((tup = systable_getnext(scan))))
+	{
+		Form_pg_depend dep = (Form_pg_depend) GETSTRUCT(tup);
+		Oid			classid;
+		Oid			objectid;
+
+		if (dep->deptype != DEPENDENCY_KEYJOIN)
+			continue;
+
+		if (dep->classid == RewriteRelationId)
+		{
+			classid = RelationRelationId;
+			objectid = get_rule_event_relation(dep->objid);
+			if (!OidIsValid(objectid))
+				continue;
+		}
+		else if (dep->classid == ProcedureRelationId)
+		{
+			classid = ProcedureRelationId;
+			objectid = dep->objid;
+		}
+		else
+		{
+			Assert(dep->classid == PolicyRelationId);
+			classid = PolicyRelationId;
+			objectid = dep->objid;
+		}
+
+		if (classid == refclassid && objectid == refobjid)
+			continue;
+
+		if (!object_address_list_member(result, classid, objectid))
+			result = lappend(result, make_object_address(classid, objectid));
+	}
+
+	systable_endscan(scan);
+	table_close(depRel, AccessShareLock);
+
+	list_sort(result, object_address_list_cmp);
+	return result;
+}
+
+static void
+revalidate_dependent_key_join_objects(List *objects)
+{
+	foreach_ptr(ObjectAddress, object, objects)
+		revalidate_dependent_key_join_object(object);
+}
+
+static void
+revalidate_dependent_key_join_object(const ObjectAddress *object)
+{
+	switch (object->classId)
+	{
+		case RelationRelationId:
+			revalidate_dependent_key_join_relation(object->objectId);
+			break;
+		case ProcedureRelationId:
+			revalidate_dependent_key_join_function(object->objectId);
+			break;
+		default:
+			Assert(object->classId == PolicyRelationId);
+			revalidate_dependent_key_join_policy(object->objectId);
+			break;
+	}
+}
+
+static void
+revalidate_dependent_key_join_relation(Oid relationOid)
+{
+	Relation	rel;
+
+	rel = relation_open(relationOid, AccessShareLock);
+
+	/*
+	 * Walk every rule attached to the dependent relation.  A stored key-join
+	 * proof can live in any rule action: a view's or matview's _RETURN rule,
+	 * an INSTEAD-OF rule on a view, a DO ALSO/INSTEAD rule on a plain table,
+	 * etc.  Each key-join-bearing action must be revalidated so DDL that
+	 * would make the proof unprovable is rejected with the existing "key join
+	 * cannot be proven from available constraints" error.
+	 *
+	 * Revalidation may change copied KeyJoinNode dependency lists.
+	 * Dependency shrinkage is safe to leave stale in pg_depend, but new
+	 * dependencies or other semantic changes would make the stored proof
+	 * unsafe without rewriting its owning object.
+	 */
+	if (rel->rd_rules == NULL || rel->rd_rules->numLocks == 0)
+	{
+		/*
+		 * A rule can be dropped after pg_depend is scanned but before this
+		 * relation lock is acquired.  If no rules remain, there is no stored
+		 * proof left on this relation to revalidate.
+		 */
+		relation_close(rel, AccessShareLock);
+		return;
+	}
+	for (int i = 0; i < rel->rd_rules->numLocks; i++)
+	{
+		RewriteRule *rule = rel->rd_rules->rules[i];
+
+		foreach_node(Query, action, rule->actions)
+			revalidate_stored_key_join_node((Node *) action);
+
+		if (rule->qual != NULL)
+			revalidate_stored_key_join_node(rule->qual);
+	}
+
+	relation_close(rel, NoLock);
+}
+
+static void
+revalidate_dependent_key_join_function(Oid procOid)
+{
+	HeapTuple	tup;
+	Datum		datum;
+	bool		isnull;
+	Node	   *body;
+
+	INJECTION_POINT("key-join-before-dependent-function-lookup", NULL);
+
+	tup = SearchSysCache1(PROCOID, ObjectIdGetDatum(procOid));
+	if (!HeapTupleIsValid(tup))
+	{
+		/*
+		 * A function can be dropped after pg_depend is scanned but before
+		 * this lookup reaches pg_proc.  Its stored proof no longer exists.
+		 */
+		return;
+	}
+
+	datum = SysCacheGetAttr(PROCOID, tup, Anum_pg_proc_prosqlbody, &isnull);
+	if (isnull)
+	{
+		ReleaseSysCache(tup);
+		return;
+	}
+
+	body = stringToNode(TextDatumGetCString(datum));
+	ReleaseSysCache(tup);
+
+	revalidate_stored_key_join_node(body);
+}
+
+static void
+revalidate_dependent_key_join_policy(Oid policy_id)
+{
+	Relation	pg_policy_rel;
+	ScanKeyData skey[1];
+	SysScanDesc sscan;
+	HeapTuple	policy_tuple;
+	TupleDesc	policy_desc;
+	Datum		expr_datum;
+	bool		expr_isnull;
+
+	pg_policy_rel = table_open(PolicyRelationId, AccessShareLock);
+
+	ScanKeyInit(&skey[0],
+				Anum_pg_policy_oid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(policy_id));
+
+	sscan = systable_beginscan(pg_policy_rel, PolicyOidIndexId, true, NULL,
+							   1, skey);
+	policy_tuple = systable_getnext(sscan);
+
+	if (!HeapTupleIsValid(policy_tuple))
+	{
+		/*
+		 * A policy can be dropped after pg_depend is scanned but before this
+		 * lookup acquires pg_policy.  In that case its stored proof is gone.
+		 */
+		systable_endscan(sscan);
+		table_close(pg_policy_rel, AccessShareLock);
+		return;
+	}
+
+	policy_desc = RelationGetDescr(pg_policy_rel);
+
+	expr_datum = heap_getattr(policy_tuple, Anum_pg_policy_polqual,
+							  policy_desc, &expr_isnull);
+	if (!expr_isnull)
+		revalidate_stored_key_join_node(
+			stringToNode(TextDatumGetCString(expr_datum)));
+
+	expr_datum = heap_getattr(policy_tuple, Anum_pg_policy_polwithcheck,
+							  policy_desc, &expr_isnull);
+	if (!expr_isnull)
+		revalidate_stored_key_join_node(
+			stringToNode(TextDatumGetCString(expr_datum)));
+
+	systable_endscan(sscan);
+	table_close(pg_policy_rel, AccessShareLock);
+}
+
+static void
+revalidate_stored_key_join_node(Node *stored)
+{
+	Node	   *copy;
+
+	Assert(stored != NULL);
+
+	if (!storedNodeContainsKeyJoin(stored))
+		return;
+
+	copy = copyObject(stored);
+	revalidateStoredKeyJoinProofsInNode(copy);
+
+	if (!revalidatedStoredKeyJoinProofsAreSafe(stored, copy))
+		ereport(ERROR,
+				(errcode(ERRCODE_INVALID_FOREIGN_KEY),
+				 errmsg("stored key join proof would require new dependencies")));
+}
+
+void
+RevalidateDependentKeyJoinObjectsOnConstraint(Oid constraintOid)
+{
+	List	   *objects;
+
+	Assert(OidIsValid(constraintOid));
+
+	objects = find_dependent_key_join_objects(ConstraintRelationId,
+											  constraintOid);
+	revalidate_dependent_key_join_objects(objects);
+}
+
+void
+RevalidateDependentKeyJoinObjectsOnRelation(Oid relationOid)
+{
+	List	   *objects;
+
+	Assert(OidIsValid(relationOid));
+
+	objects = find_dependent_key_join_objects(RelationRelationId,
+											  relationOid);
+	revalidate_dependent_key_join_objects(objects);
+}
+
+void
+RevalidateDependentKeyJoinObjectsOnProcedure(Oid procOid)
+{
+	List	   *objects;
+
+	Assert(OidIsValid(procOid));
+
+	/*
+	 * CREATE OR REPLACE FUNCTION transforms a new-style SQL function body
+	 * before the replacement tuple is visible.  If that body proves a key
+	 * join through objects that refer back to the function, it may have used
+	 * the old function properties; validate the function's own body once
+	 * against the final catalog state before checking direct dependents.
+	 */
+	revalidate_dependent_key_join_function(procOid);
+	objects = find_dependent_key_join_objects(ProcedureRelationId, procOid);
+	revalidate_dependent_key_join_objects(objects);
+}
+
+static bool
+object_address_list_member(List *objects, Oid classId, Oid objectId)
+{
+	foreach_ptr(ObjectAddress, object, objects)
+	{
+		Assert(object->objectSubId == 0);
+
+		if (object->classId == classId && object->objectId == objectId)
+			return true;
+	}
+	return false;
+}
+
+static int
+object_address_list_cmp(const ListCell *a, const ListCell *b)
+{
+	const ObjectAddress *obj1 = (const ObjectAddress *) lfirst(a);
+	const ObjectAddress *obj2 = (const ObjectAddress *) lfirst(b);
+	int			cmp;
+
+	Assert(obj1->objectSubId == 0);
+	Assert(obj2->objectSubId == 0);
+
+	cmp = pg_cmp_u32(obj1->classId, obj2->classId);
+	if (cmp != 0)
+		return cmp;
+	return pg_cmp_u32(obj1->objectId, obj2->objectId);
+}
+
+static ObjectAddress *
+make_object_address(Oid classId, Oid objectId)
+{
+	ObjectAddress *object = palloc_object(ObjectAddress);
+
+	ObjectAddressSet(*object, classId, objectId);
+	return object;
+}
diff --git a/src/backend/commands/meson.build b/src/backend/commands/meson.build
index 9f258d566eb..f9f89226885 100644
--- a/src/backend/commands/meson.build
+++ b/src/backend/commands/meson.build
@@ -28,6 +28,7 @@ backend_sources += files(
   'foreigncmds.c',
   'functioncmds.c',
   'indexcmds.c',
+  'keyjoincmds.c',
   'lockcmds.c',
   'matview.c',
   'opclasscmds.c',
diff --git a/src/backend/commands/policy.c b/src/backend/commands/policy.c
index 21b8eebe32d..afbc73d743b 100644
--- a/src/backend/commands/policy.c
+++ b/src/backend/commands/policy.c
@@ -726,9 +726,11 @@ CreatePolicy(CreatePolicyStmt *stmt)
 
 	recordDependencyOnExpr(&myself, qual, qual_pstate->p_rtable,
 						   DEPENDENCY_NORMAL);
+	recordDependencyOnKeyJoinProofs(&myself, qual);
 
 	recordDependencyOnExpr(&myself, with_check_qual,
 						   with_check_pstate->p_rtable, DEPENDENCY_NORMAL);
+	recordDependencyOnKeyJoinProofs(&myself, with_check_qual);
 
 	/* Register role dependencies */
 	target.classId = AuthIdRelationId;
@@ -1056,9 +1058,11 @@ AlterPolicy(AlterPolicyStmt *stmt)
 	recordDependencyOn(&myself, &target, DEPENDENCY_AUTO);
 
 	recordDependencyOnExpr(&myself, qual, qual_parse_rtable, DEPENDENCY_NORMAL);
+	recordDependencyOnKeyJoinProofs(&myself, qual);
 
 	recordDependencyOnExpr(&myself, with_check_qual, with_check_parse_rtable,
 						   DEPENDENCY_NORMAL);
+	recordDependencyOnKeyJoinProofs(&myself, with_check_qual);
 
 	/* Register role dependencies */
 	deleteSharedDependencyRecordsFor(PolicyRelationId, policy_id, 0);
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index a1845240a98..c8adddcb009 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -62,6 +62,7 @@
 #include "commands/defrem.h"
 #include "commands/event_trigger.h"
 #include "commands/extension.h"
+#include "commands/keyjoin.h"
 #include "commands/repack.h"
 #include "commands/sequence.h"
 #include "commands/tablecmds.h"
@@ -3655,6 +3656,19 @@ StoreCatalogInheritance1(Oid relationId, Oid parentOid,
 	 * Mark the parent as having subclasses.
 	 */
 	SetRelationHasSubclass(parentOid, true);
+
+	/*
+	 * Revalidate stored key-join proofs that depend on this parent.  For
+	 * ordinary inheritance, an inh=true scan can now fan out via children that
+	 * do not enforce the parent's unique constraint, so
+	 * compute_key_join_relation_facts() will stop exposing that inherited
+	 * parent as a key-join base surface.  Partition attachment normally keeps
+	 * partitioned-parent proofs valid, but replay verifies that against the
+	 * visible catalog state.  CommandCounterIncrement makes the new pg_inherits
+	 * row and relhassubclass flag visible to has_subclass().
+	 */
+	CommandCounterIncrement();
+	RevalidateDependentKeyJoinObjectsOnRelation(parentOid);
 }
 
 /*
@@ -12557,6 +12571,8 @@ ATExecAlterFKConstrEnforceability(List **wqueue, ATAlterConstraint *cmdcon,
 	if (currcon->conenforced != cmdcon->is_enforced)
 	{
 		AlterConstrUpdateConstraintEntry(cmdcon, conrel, contuple);
+		CommandCounterIncrement();
+		RevalidateDependentKeyJoinObjectsOnConstraint(conoid);
 		changed = true;
 	}
 
@@ -12861,6 +12877,8 @@ ATExecAlterConstrDeferrability(List **wqueue, ATAlterConstraint *cmdcon,
 		currcon->condeferred != cmdcon->initdeferred)
 	{
 		AlterConstrUpdateConstraintEntry(cmdcon, conrel, contuple);
+		CommandCounterIncrement();
+		RevalidateDependentKeyJoinObjectsOnConstraint(currcon->oid);
 		changed = true;
 
 		/*
@@ -12915,6 +12933,7 @@ ATExecAlterConstrInheritability(List **wqueue, ATAlterConstraint *cmdcon,
 
 	AlterConstrUpdateConstraintEntry(cmdcon, conrel, contuple);
 	CommandCounterIncrement();
+	RevalidateDependentKeyJoinObjectsOnConstraint(currcon->oid);
 
 	/* Fetch the column number and name */
 	colNum = extractNotNullColumn(contuple);
@@ -18879,6 +18898,7 @@ ATExecSetRowSecurity(Relation rel, bool rls)
 	Relation	pg_class;
 	Oid			relid;
 	HeapTuple	tuple;
+	bool		prev_rls;
 
 	relid = RelationGetRelid(rel);
 
@@ -18890,6 +18910,7 @@ ATExecSetRowSecurity(Relation rel, bool rls)
 	if (!HeapTupleIsValid(tuple))
 		elog(ERROR, "cache lookup failed for relation %u", relid);
 
+	prev_rls = ((Form_pg_class) GETSTRUCT(tuple))->relrowsecurity;
 	((Form_pg_class) GETSTRUCT(tuple))->relrowsecurity = rls;
 	CatalogTupleUpdate(pg_class, &tuple->t_self, tuple);
 
@@ -18898,6 +18919,21 @@ ATExecSetRowSecurity(Relation rel, bool rls)
 
 	table_close(pg_class, RowExclusiveLock);
 	heap_freetuple(tuple);
+
+	/*
+	 * Revalidate stored key-join proofs that depend on this relation.  The
+	 * key-join base-fact computation refuses to expose facts for a relation
+	 * with row-level security enabled (parse_key_join.c), so flipping
+	 * relrowsecurity can leave a stored proof unprovable.  Revalidate
+	 * whenever the flag actually changes; for the off-to-on transition the
+	 * revalidation raises an error and aborts this DDL, while the on-to-off
+	 * transition is a no-op for proofs that were already valid.
+	 */
+	if (rls != prev_rls)
+	{
+		CommandCounterIncrement();
+		RevalidateDependentKeyJoinObjectsOnRelation(relid);
+	}
 }
 
 /*
diff --git a/src/backend/commands/view.c b/src/backend/commands/view.c
index 1bd78a4cdf0..166f80b852f 100644
--- a/src/backend/commands/view.c
+++ b/src/backend/commands/view.c
@@ -17,6 +17,7 @@
 #include "access/relation.h"
 #include "access/xact.h"
 #include "catalog/namespace.h"
+#include "commands/keyjoin.h"
 #include "commands/tablecmds.h"
 #include "commands/view.h"
 #include "nodes/makefuncs.h"
@@ -181,6 +182,7 @@ DefineVirtualRelation(RangeVar *relation, List *tlist, bool replace,
 
 		/* Make the new view query visible */
 		CommandCounterIncrement();
+		RevalidateDependentKeyJoinObjectsOnRelation(viewOid);
 
 		/*
 		 * Update the view's options.
diff --git a/src/backend/nodes/nodeFuncs.c b/src/backend/nodes/nodeFuncs.c
index 2a2e00b372e..d65c63e60e6 100644
--- a/src/backend/nodes/nodeFuncs.c
+++ b/src/backend/nodes/nodeFuncs.c
@@ -2413,6 +2413,16 @@ expression_tree_walker_impl(Node *node,
 			return WALK(((NullTest *) node)->arg);
 		case T_BooleanTest:
 			return WALK(((BooleanTest *) node)->arg);
+		case T_KeyJoinProofDependency:
+			break;
+		case T_KeyJoinNode:
+			break;
+		case T_KeyJoinClause:
+			{
+				KeyJoinClause *key_join = (KeyJoinClause *) node;
+
+				return WALK(key_join->filter);
+			}
 		case T_CoerceToDomain:
 			return WALK(((CoerceToDomain *) node)->arg);
 		case T_TargetEntry:
@@ -2630,9 +2640,13 @@ expression_tree_walker_impl(Node *node,
 					return true;
 				if (WALK(join->quals))
 					return true;
+				if (WALK(join->joinFilter))
+					return true;
 
 				/*
-				 * alias clause, using list are deemed uninteresting.
+				 * Key-join proof metadata is not expression semantics, but the
+				 * join-local filter is.  Alias clause, using list are deemed
+				 * uninteresting.
 				 */
 			}
 			break;
@@ -3527,6 +3541,39 @@ expression_tree_mutator_impl(Node *node,
 				return (Node *) newnode;
 			}
 			break;
+		case T_KeyJoinProofDependency:
+			return copyObject(node);
+		case T_KeyJoinNode:
+			{
+				KeyJoinNode *key_join = (KeyJoinNode *) node;
+				KeyJoinNode *newnode;
+
+				FLATCOPY(newnode, key_join, KeyJoinNode);
+				newnode->referencingAttnums =
+					copyObject(key_join->referencingAttnums);
+				newnode->referencedAttnums =
+					copyObject(key_join->referencedAttnums);
+				newnode->refAliasAttnums =
+					copyObject(key_join->refAliasAttnums);
+				newnode->notNullConstraints =
+					copyObject(key_join->notNullConstraints);
+				newnode->proofDependencies =
+					copyObject(key_join->proofDependencies);
+				return (Node *) newnode;
+			}
+		case T_KeyJoinClause:
+			{
+				KeyJoinClause *key_join = (KeyJoinClause *) node;
+				KeyJoinClause *newnode;
+
+				FLATCOPY(newnode, key_join, KeyJoinClause);
+				newnode->localCols = copyObject(key_join->localCols);
+				newnode->refAlias = key_join->refAlias ?
+					pstrdup(key_join->refAlias) : NULL;
+				newnode->refCols = copyObject(key_join->refCols);
+				MUTATE(newnode->filter, key_join->filter, Node *);
+				return (Node *) newnode;
+			}
 		case T_CoerceToDomain:
 			{
 				CoerceToDomain *ctest = (CoerceToDomain *) node;
@@ -3722,6 +3769,8 @@ expression_tree_mutator_impl(Node *node,
 				MUTATE(newnode->larg, join->larg, Node *);
 				MUTATE(newnode->rarg, join->rarg, Node *);
 				MUTATE(newnode->quals, join->quals, Node *);
+				newnode->keyJoin = copyObject(join->keyJoin);
+				MUTATE(newnode->joinFilter, join->joinFilter, Node *);
 				/* We do not mutate alias or using by default */
 				return (Node *) newnode;
 			}
@@ -4326,6 +4375,26 @@ raw_expression_tree_walker_impl(Node *node,
 			return WALK(((NullTest *) node)->arg);
 		case T_BooleanTest:
 			return WALK(((BooleanTest *) node)->arg);
+		case T_KeyJoinProofDependency:
+			break;
+		case T_KeyJoinNode:
+			{
+				KeyJoinNode *key_join = (KeyJoinNode *) node;
+
+				if (WALK(key_join->notNullConstraints))
+					return true;
+				return WALK(key_join->proofDependencies);
+			}
+		case T_KeyJoinClause:
+			{
+				KeyJoinClause *kjc = (KeyJoinClause *) node;
+
+				if (WALK(kjc->localCols))
+					return true;
+				if (WALK(kjc->refCols))
+					return true;
+				return WALK(kjc->filter);
+			}
 		case T_JoinExpr:
 			{
 				JoinExpr   *join = (JoinExpr *) node;
@@ -4336,6 +4405,10 @@ raw_expression_tree_walker_impl(Node *node,
 					return true;
 				if (WALK(join->quals))
 					return true;
+				if (WALK(join->keyJoin))
+					return true;
+				if (WALK(join->joinFilter))
+					return true;
 				if (WALK(join->alias))
 					return true;
 				/* using list is deemed uninteresting */
diff --git a/src/backend/parser/Makefile b/src/backend/parser/Makefile
index 8b5a4af6bf2..1b461d612be 100644
--- a/src/backend/parser/Makefile
+++ b/src/backend/parser/Makefile
@@ -16,6 +16,7 @@ OBJS = \
 	analyze.o \
 	gram.o \
 	parse_agg.o \
+	parse_key_join.o \
 	parse_clause.o \
 	parse_coerce.o \
 	parse_collate.o \
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index ff4e1388c55..e1a1ed4d99f 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -14566,6 +14566,8 @@ joined_table:
 					n->rarg = $4;
 					n->usingClause = NIL;
 					n->join_using_alias = NULL;
+					n->keyJoin = NULL;
+					n->joinFilter = NULL;
 					n->quals = NULL;
 					$$ = n;
 				}
@@ -14579,10 +14581,16 @@ joined_table:
 					n->rarg = $4;
 					if ($5 != NULL && IsA($5, List))
 					{
-						 /* USING clause */
+						/* USING clause */
 						n->usingClause = linitial_node(List, castNode(List, $5));
 						n->join_using_alias = lsecond_node(Alias, castNode(List, $5));
 					}
+					else if ($5 != NULL && IsA($5, KeyJoinClause))
+					{
+						/* KEY clause */
+						n->keyJoin = (Node *) $5;
+						n->joinFilter = ((KeyJoinClause *) $5)->filter;
+					}
 					else
 					{
 						/* ON clause */
@@ -14605,6 +14613,12 @@ joined_table:
 						n->usingClause = linitial_node(List, castNode(List, $4));
 						n->join_using_alias = lsecond_node(Alias, castNode(List, $4));
 					}
+					else if ($4 != NULL && IsA($4, KeyJoinClause))
+					{
+						/* KEY clause */
+						n->keyJoin = (Node *) $4;
+						n->joinFilter = ((KeyJoinClause *) $4)->filter;
+					}
 					else
 					{
 						/* ON clause */
@@ -14622,6 +14636,8 @@ joined_table:
 					n->rarg = $5;
 					n->usingClause = NIL; /* figure out which columns later... */
 					n->join_using_alias = NULL;
+					n->keyJoin = NULL;
+					n->joinFilter = NULL;
 					n->quals = NULL; /* fill later */
 					$$ = n;
 				}
@@ -14636,6 +14652,8 @@ joined_table:
 					n->rarg = $4;
 					n->usingClause = NIL; /* figure out which columns later... */
 					n->join_using_alias = NULL;
+					n->keyJoin = NULL;
+					n->joinFilter = NULL;
 					n->quals = NULL; /* fill later */
 					$$ = n;
 				}
@@ -14750,6 +14768,28 @@ join_qual: USING '(' name_list ')' opt_alias_clause_for_join_using
 				{
 					$$ = $2;
 				}
+			| FOR KEY '(' name_list ')' '<' '-' ColId '(' name_list ')' filter_clause
+				{
+					KeyJoinClause *n = makeNode(KeyJoinClause);
+					n->localCols = $4;
+					n->direction = KEY_JOIN_LEFT_ARROW;
+					n->refAlias = $8;
+					n->refCols = $10;
+					n->filter = $12;
+					n->location = @1;
+					$$ = (Node *) n;
+				}
+			| FOR KEY '(' name_list ')' RIGHT_ARROW ColId '(' name_list ')' filter_clause
+				{
+					KeyJoinClause *n = makeNode(KeyJoinClause);
+					n->localCols = $4;
+					n->direction = KEY_JOIN_RIGHT_ARROW;
+					n->refAlias = $7;
+					n->refCols = $9;
+					n->filter = $11;
+					n->location = @1;
+					$$ = (Node *) n;
+				}
 		;
 
 
diff --git a/src/backend/parser/meson.build b/src/backend/parser/meson.build
index 86c09b29ec2..4f314178431 100644
--- a/src/backend/parser/meson.build
+++ b/src/backend/parser/meson.build
@@ -3,6 +3,7 @@
 backend_sources += files(
   'analyze.c',
   'parse_agg.c',
+  'parse_key_join.c',
   'parse_clause.c',
   'parse_coerce.c',
   'parse_collate.c',
diff --git a/src/backend/parser/parse_clause.c b/src/backend/parser/parse_clause.c
index 5fe5257b019..8236cb7ac26 100644
--- a/src/backend/parser/parse_clause.c
+++ b/src/backend/parser/parse_clause.c
@@ -35,6 +35,7 @@
 #include "parser/parse_coerce.h"
 #include "parser/parse_collate.h"
 #include "parser/parse_expr.h"
+#include "parser/parse_key_join.h"
 #include "parser/parse_func.h"
 #include "parser/parse_graphtable.h"
 #include "parser/parse_oper.h"
@@ -58,8 +59,8 @@ static int	extractRemainingColumns(ParseState *pstate,
 									ParseNamespaceColumn *res_nscolumns);
 static Node *transformJoinUsingClause(ParseState *pstate,
 									  List *leftVars, List *rightVars);
-static Node *transformJoinOnClause(ParseState *pstate, JoinExpr *j,
-								   List *namespace);
+static Node *transformJoinQualClause(ParseState *pstate, Node *qual,
+									 List *namespace, const char *constructName);
 static ParseNamespaceItem *transformTableEntry(ParseState *pstate, RangeVar *r);
 static ParseNamespaceItem *transformRangeSubselect(ParseState *pstate,
 												   RangeSubselect *r);
@@ -352,7 +353,7 @@ transformJoinUsingClause(ParseState *pstate,
 	/*
 	 * Since the references are already Vars, and are certainly from the input
 	 * relations, we don't have to go through the same pushups that
-	 * transformJoinOnClause() does.  Just invoke transformExpr() to fix up
+	 * transformJoinQualClause() does.  Just invoke transformExpr() to fix up
 	 * the operators, and we're done.
 	 */
 	result = transformExpr(pstate, result, EXPR_KIND_JOIN_USING);
@@ -363,12 +364,13 @@ transformJoinUsingClause(ParseState *pstate,
 }
 
 /*
- * transformJoinOnClause()
- *	  Transform the qual conditions for JOIN/ON.
+ * transformJoinQualClause()
+ *	  Transform the qual conditions for JOIN/ON or join FILTER.
  *	  Result is a transformed qualification expression.
  */
 static Node *
-transformJoinOnClause(ParseState *pstate, JoinExpr *j, List *namespace)
+transformJoinQualClause(ParseState *pstate, Node *qual, List *namespace,
+						const char *constructName)
 {
 	Node	   *result;
 	List	   *save_namespace;
@@ -386,8 +388,8 @@ transformJoinOnClause(ParseState *pstate, JoinExpr *j, List *namespace)
 	save_namespace = pstate->p_namespace;
 	pstate->p_namespace = namespace;
 
-	result = transformWhereClause(pstate, j->quals,
-								  EXPR_KIND_JOIN_ON, "JOIN/ON");
+	result = transformWhereClause(pstate, qual,
+								  EXPR_KIND_JOIN_ON, constructName);
 
 	pstate->p_namespace = save_namespace;
 
@@ -1359,7 +1361,7 @@ transformFromClauseItem(ParseState *pstate, Node *n,
 		/*
 		 * Generate combined namespace info for possible use below.
 		 */
-		my_namespace = list_concat(l_namespace, r_namespace);
+		my_namespace = list_concat_copy(l_namespace, r_namespace);
 
 		/*
 		 * We'll work from the nscolumns data and eref alias column names for
@@ -1546,13 +1548,44 @@ transformFromClauseItem(ParseState *pstate, Node *n,
 		else if (j->quals)
 		{
 			/* User-written ON-condition; transform it */
-			j->quals = transformJoinOnClause(pstate, j, my_namespace);
+			j->quals = transformJoinQualClause(pstate, j->quals,
+											   my_namespace, "JOIN/ON");
+		}
+		else if (j->keyJoin)
+		{
+			transformAndValidateKeyJoin(pstate, j, l_nsitem, r_nsitem,
+										l_namespace);
 		}
 		else
 		{
 			/* CROSS JOIN: no quals */
 		}
 
+		/*
+		 * Transform and merge FILTER clause for FOR KEY joins.  This
+		 * intentionally happens after validation:
+		 * transformAndValidateKeyJoin proves only the operand surfaces plus
+		 * the generated FK equality.  Join-local FILTER is ordinary join-qual
+		 * semantics applied to the already-proven key-join result, so an inner
+		 * key join may still return fewer rows after the filter.
+		 *
+		 * The filter goes into JoinExpr.quals (ON clause semantics) and is
+		 * also preserved on joinFilter for deparsing and output fact export.
+		 */
+		if (j->joinFilter != NULL)
+		{
+			Assert(j->keyJoin != NULL);
+
+			j->joinFilter = transformJoinQualClause(pstate, j->joinFilter,
+													my_namespace, "FILTER");
+
+			Assert(j->quals != NULL);
+			j->quals = (Node *)
+				makeBoolExpr(AND_EXPR,
+							 list_make2(j->quals, copyObject(j->joinFilter)),
+							 -1);
+		}
+
 		/*
 		 * If this is an outer join, now mark the appropriate child RTEs as
 		 * being nulled by this join.  We have finished processing the child
diff --git a/src/backend/parser/parse_cte.c b/src/backend/parser/parse_cte.c
index ccde199319a..20f0ac5976e 100644
--- a/src/backend/parser/parse_cte.c
+++ b/src/backend/parser/parse_cte.c
@@ -1178,6 +1178,7 @@ checkWellFormedRecursionWalker(Node *node, CteState *cstate)
 				elog(ERROR, "unrecognized join type: %d",
 					 (int) j->jointype);
 		}
+		checkWellFormedRecursionWalker(j->joinFilter, cstate);
 		return false;
 	}
 	if (IsA(node, SubLink))
diff --git a/src/backend/parser/parse_key_join.c b/src/backend/parser/parse_key_join.c
new file mode 100644
index 00000000000..6e1915b09f7
--- /dev/null
+++ b/src/backend/parser/parse_key_join.c
@@ -0,0 +1,5063 @@
+/*-------------------------------------------------------------------------
+ *
+ * parse_key_join.c
+ *	  handle key joins in parser
+ *
+ * A key join is accepted only after parse analysis proves that its rewritten
+ * equijoin satisfies these conditions over the named referencing and
+ * referenced columns:
+ *
+ *	  1. The referenced values are unique.
+ *	  2. Every non-null referencing value is contained in those referenced
+ *		 values.
+ *	  3. Referencing values are non-null, unless the join type preserves
+ *		 that side.
+ *
+ * Surface facts are transient parser summaries attached to RangeTblEntry
+ * nodes while proving FOR KEY joins.  They are computed on demand and are
+ * not part of stored query semantics.
+ *
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/backend/parser/parse_key_join.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "access/genam.h"
+#include "access/htup_details.h"
+#include "access/relation.h"
+#include "catalog/dependency.h"
+#include "catalog/index.h"
+#include "catalog/indexing.h"
+#include "catalog/pg_class.h"
+#include "catalog/pg_constraint.h"
+#include "catalog/pg_inherits.h"
+#include "catalog/pg_operator.h"
+#include "catalog/pg_proc.h"
+#include "catalog/pg_type.h"
+#include "lib/stringinfo.h"
+#include "nodes/makefuncs.h"
+#include "nodes/nodeFuncs.h"
+#include "nodes/parsenodes.h"
+#include "optimizer/clauses.h"
+#include "optimizer/optimizer.h"
+#include "parser/parse_coerce.h"
+#include "parser/parse_key_join.h"
+#include "parser/parse_relation.h"
+#include "parser/parsetree.h"
+#include "rewrite/rewriteHandler.h"
+#include "storage/lmgr.h"
+#include "utils/builtins.h"
+#include "utils/errcodes.h"
+#include "utils/fmgroids.h"
+#include "utils/injection_point.h"
+#include "utils/lsyscache.h"
+#include "utils/rel.h"
+#include "utils/relcache.h"
+#include "utils/syscache.h"
+
+/*
+ * KeyJoinColumn
+ *
+ *		Resolved form of one column named by a raw key-join clause.
+ *		Tracks both the namespace item column and the exposed operand
+ *		surface column used by proof facts.
+ */
+typedef struct KeyJoinColumn
+{
+	char	   *name;
+	ParseNamespaceItem *nsitem;
+	AttrNumber	nsattno;
+	AttrNumber	surface_attno;
+	ParseNamespaceColumn *nscol;
+} KeyJoinColumn;
+
+/*
+ * KeyJoinReq
+ *
+ *		Proof requirements in the order find_key_join_match checks them; on
+ *		failure it reports the first unmet one via the KeyJoinMatch fail*
+ *		fields.
+ */
+typedef enum KeyJoinReq
+{
+	REQ_NONE = 0,
+	REQ_FK,						/* referencing FK fact covering the columns */
+	REQ_UNIQUE,					/* referenced unique fact for the FK target */
+	REQ_COVERAGE,				/* referenced row-coverage fact */
+	REQ_FKPAIR,					/* FK pairs the named columns (cond. 2a) */
+	REQ_FILTER,					/* referenced filters remap (cond. 2c) */
+	REQ_NOTNULL,				/* referencing not-null evidence (cond. 3) */
+} KeyJoinReq;
+
+/*
+ * KeyJoinMatch
+ *
+ *		Complete proof selected for one validated key join.
+ *		The parser uses this to build equality quals and to record catalog
+ *		dependencies in the stored KeyJoinNode.
+ */
+typedef struct KeyJoinMatch
+{
+	Oid			constraint;
+	List	   *eqoperators;
+	List	   *eqtypes;
+	List	   *eqtypmods;
+	List	   *notnulldeps;
+	List	   *proofdeps;
+	/* Filled only on failure, for the rejection message. */
+	KeyJoinReq	failReq;
+	bool		failInactivated;	/* a matching fact went inactive */
+	KeyJoinInactiveReason failReason;
+	Oid			failOriginView;
+} KeyJoinMatch;
+
+/*
+ * KeyJoinFailureSide
+ *
+ *		Display fields for one side of an unproven key join.  Live parse
+ *		analysis has raw KeyJoinColumn names; stored-query revalidation has
+ *		only RTE aliases and attnums.
+ */
+typedef struct KeyJoinFailureSide
+{
+	const char *alias;
+	KeyJoinColumn *columns;
+	int			ncolumns;
+	RangeTblEntry *rte;
+	List	   *attnums;
+} KeyJoinFailureSide;
+
+/*
+ * KeyJoinQueryStack
+ *
+ *		Stored query revalidation has no ParseState chain.  Keep the copied
+ *		Query ownership stack explicitly so CTE RTEs can resolve ctelevelsup
+ *		without guessing or exposing facts from the wrong WITH level.
+ */
+typedef struct KeyJoinQueryStack
+{
+	struct KeyJoinQueryStack *parent;
+	Query	   *query;
+} KeyJoinQueryStack;
+
+/*
+ * KeyJoinFactContext
+ *
+ *		Fact computation runs either during live parse analysis or while
+ *		revalidating a copied stored Query.  Keep that mode explicit instead
+ *		of spreading nullable ParseState/Query arguments through the proof
+ *		code.  revalidating_stored_query is set only for projection contexts
+ *		created after the owner-aware stored proof replay has already visited
+ *		the relevant CTE and FROM-subquery Query trees.
+ */
+typedef struct KeyJoinFactContext
+{
+	ParseState *pstate;
+	Query	   *query;
+	KeyJoinQueryStack *query_stack;
+	bool		revalidating_stored_query;
+} KeyJoinFactContext;
+
+/*
+ * FKFilterRemapContext
+ *
+ *		State for remapping proof-filter Params between key lists.
+ */
+typedef struct FKFilterRemapContext
+{
+	List	   *position_map;
+} FKFilterRemapContext;
+
+/*
+ * KeyJoinRowCollapse
+ *
+ *		One GROUP BY or DISTINCT stage a row-coverage key must survive, with the
+ *		reason to record on the fact if it does not.
+ */
+typedef struct KeyJoinRowCollapse
+{
+	KeyJoinInactiveReason reason;	/* KJI_GROUP_BY or KJI_DISTINCT */
+	List	   *positions;			/* of KeyJoinKeyPosition */
+} KeyJoinRowCollapse;
+
+static bool find_key_join_match(RangeTblEntry *referencing_rte,
+								RangeTblEntry *referenced_rte,
+								List *referencing_attnums,
+								List *referenced_attnums,
+								bool need_notnull, KeyJoinMatch *match);
+static Node *remap_filter_conjunct(Node *conjunct, List *position_map);
+static bool filter_conjunct_can_remap(Node *conjunct, List *position_map);
+static bool filter_conjunct_matches_key_positions(Node *conjunct,
+												  List *keyPositions);
+static bool filter_value_allowed(Node *node);
+static void ensure_key_join_surface_facts(KeyJoinFactContext *context,
+										  RangeTblEntry *rte);
+static void compute_key_join_relation_facts(KeyJoinFactContext *context,
+											RangeTblEntry *rte,
+											Relation rel);
+static KeyJoinSurfaceFacts *project_key_join_query_facts(KeyJoinFactContext *context,
+														 Query *query);
+static JoinExpr *find_join_expr_for_rtindex(KeyJoinFactContext *context,
+											Index rtindex);
+static void project_key_join_facts_from_rte(KeyJoinSurfaceFacts *dst,
+											RangeTblEntry *src, List **attrmap,
+											bool preserve_notnull,
+											bool preserve_unique,
+											bool preserve_rowcoverage,
+											Node *filter_qual, Query *filter_query,
+											Node *filter_jtnode, Index filter_rtindex,
+											List *rowcoverage_key_position_sets,
+											List *extra_unique_deps,
+											KeyJoinInactiveReason inact_reason);
+static List *make_rowcollapse_key_positions(Query *query, List *clauses);
+static bool add_filter_conjuncts(List **dst, List *keyPositions,
+								 Node *qual, Query *filter_query,
+								 Node *filter_jtnode, Index filter_rtindex,
+								 List **filter_attrmap, int filter_natts,
+								 List **dependencies,
+								 bool reject_lossy_filter);
+static bool key_join_contains_volatile_after_planning(Node *node);
+static bool key_join_expression_contains_volatile_after_planning(Node *node);
+static bool key_join_after_planning_query_walker(Node *node, void *context);
+static bool key_join_nested_query_walker(Node *node, void *context);
+static List *map_var_to_jtnode_surface(Query *query, Node *jtnode,
+									   Index varno, AttrNumber attno);
+static bool collect_filter_expr_dependencies_walker(Node *node,
+													void *context_arg);
+static bool filter_function_dependency_checker(Oid func_id, void *context_arg);
+static List *add_op_function_deps(List *deps, Oid opno, Oid opfuncid);
+static void compute_join_output_facts(JoinExpr *j, Index left_rtindex,
+									  RangeTblEntry *left_rte,
+									  Index right_rtindex,
+									  RangeTblEntry *right_rte,
+									  RangeTblEntry *joinrte);
+static List *collect_key_join_nodes(Node *node);
+static bool revalidate_stored_key_join_node_walker(Node *node, void *context);
+static void revalidate_stored_key_join_proofs_in_query(Query *query,
+													   KeyJoinQueryStack *parent_stack);
+static void revalidate_query_jointree_proofs(Query *query, Node *jtnode,
+											 KeyJoinQueryStack *query_stack);
+static void add_inactive_projected(KeyJoinSurfaceFacts *dst, KeyJoinFact *old,
+								   List **attrmap,
+								   KeyJoinInactiveReason reason);
+
+static KeyJoinColumn *resolve_columns_on_nsitem(ParseState *pstate,
+												ParseNamespaceItem *lookup,
+												ParseNamespaceItem *surface,
+												List *names, ParseLoc location,
+												bool is_referencing);
+static Var *make_var_from_nscolumn(ParseState *pstate,
+								   ParseNamespaceColumn *nscol);
+static bool join_preserves_side(JoinType jointype, bool leftside);
+static Node *build_key_join_quals(List *referenced_args,
+								  List *referencing_args,
+								  List *eqoperators,
+								  List *eqtypes,
+								  List *eqtypmods,
+								  List *locations,
+								  ParseLoc default_location);
+static bool select_key_position_parts(List *selected_attnums,
+									  List *keyPositions, List *baseAttnums,
+									  List **selected_base_attnums,
+									  List **selected_key_positions);
+static bool rowcollapse_preserves_rowcoverage(List *rowcoverage_key_positions,
+											  List *rowcollapse_key_positions);
+static int	key_position_index_for_attnum(List *keyPositions, int attno);
+static bool key_position_identity_lists_equal(List *left, List *right);
+static bool key_position_identity_equal(KeyJoinKeyPosition *left,
+										KeyJoinKeyPosition *right);
+static bool int_lists_same_members(List *a, List *b);
+static List *make_filter_position_map(List *src_base_attnums,
+									  List *src_selected_base,
+									  List *dst_base_attnums,
+									  List *dst_selected_base);
+static Node *remap_filter_param_mutator(Node *node, void *context_arg);
+static bool filter_conjunct_unremappable_param_walker(Node *node,
+													  void *context_arg);
+static Node *make_canonical_filter_conjunct(KeyJoinKeyPosition *keypos,
+											int pos, OpExpr *op,
+											Node *value);
+static bool filter_operator_matches_key_position(OpExpr *op,
+												 KeyJoinKeyPosition *keypos,
+												 bool lock_operator);
+static bool filter_operator_is_compatible_equality(Oid opno, Oid keyop);
+static bool list_contains_equal_node(List *list, Node *node);
+static List *append_dependencies_unique(List *dst, List *src);
+static List *append_dependency_unique(List *dependencies, Oid classId,
+									  Oid objectId);
+static bool dependency_member(List *deps, Oid classId, Oid objectId);
+static KeyJoinProofDependency *make_dependency(Oid classId, Oid objectId);
+static void append_dependency_to_facts(KeyJoinSurfaceFacts *facts,
+									   Oid classId, Oid objectId);
+static void lock_key_join_dependency_or_error(const KeyJoinProofDependency *dep);
+static void lock_key_join_dependencies_or_error(List *dependencies);
+static HeapTuple lock_and_fetch_key_join_constraint(Oid constraintOid);
+static HeapTuple lock_and_fetch_key_join_operator(Oid opno);
+static HeapTuple lock_and_fetch_key_join_proc(Oid procid);
+static Index rtindex_for_rte(KeyJoinFactContext *context,
+							 RangeTblEntry *rte);
+static JoinExpr *find_join_expr_in_jointree(Node *jtnode, Index rtindex);
+static List *project_key_positions(List *keyPositions, List **attrmap);
+static Index jtnode_surface_rtindex(Node *jtnode);
+static List *append_join_input_mapping(RangeTblEntry *joinrte, bool leftside,
+									   List *input_attnums);
+static int	join_output_attno_for_input(RangeTblEntry *joinrte,
+										bool leftside, int input_colno);
+static Var *direct_var_from_node_allow_outer(Node *node);
+static Var *direct_var_from_node(Node *node);
+static Var *direct_filter_var_from_node(Node *node);
+static Node *make_filter_param(KeyJoinKeyPosition *keypos, int pos);
+static List *append_filter_dependency(List *dependencies, Oid classId,
+									  Oid objectId);
+static List **build_join_attrmap(RangeTblEntry *joinrte, bool leftside,
+								 int nattrs);
+static bool join_null_extends_side(JoinType jointype, bool leftside);
+static Node *join_filter_for_side(JoinType jointype, bool leftside,
+								  Node *filter);
+static bool stored_node_contains_key_join_walker(Node *node, void *context);
+static bool collect_key_join_nodes_walker(Node *node, void *context);
+static bool dependency_list_is_subset(List *candidate, List *superset);
+static void extract_key_join_qual_arg(Node *qual, List **referenced_args,
+									  List **referencing_args,
+									  List **locations);
+static KeyJoinFact *add_fact(KeyJoinSurfaceFacts *set,
+							 KeyJoinFactKind kind);
+static void add_paired_row_coverage(KeyJoinSurfaceFacts *set,
+									List *keypositions, Oid relid,
+									List *baseAttnums, List *deps);
+static bool key_join_collation_is_usable(Oid collationOid);
+static bool key_join_equality_identity_is_usable(Oid typeOid, int32 typmod,
+												 Oid eqTypeOid,
+												 int32 *eqTypmod);
+static bool key_join_equality_operator_is_usable(Oid opno,
+												 Oid expectedTypeOid,
+												 Oid *eqTypeOid,
+												 List **dependencies);
+static Oid	key_join_equality_type(Oid typeOid, int32 typmod,
+								   int32 *eqTypmod);
+static List *make_key_positions_from_attrnums(const TupleDesc tupdesc,
+											  const AttrNumber *attnums,
+											  int nattnums,
+											  const Oid *eqTypes,
+											  const int32 *eqTypmods,
+											  const Oid *eqOperators);
+static KeyJoinKeyPosition *make_key_position(List *attnums, Oid typeOid,
+											 int32 typmod, Oid collationOid,
+											 Oid eqTypeOid, int32 eqTypmod,
+											 Oid eqOperator);
+static List *list_make_attrnums(const AttrNumber *attnums, int nattnums);
+static char *key_join_failure_detail(KeyJoinReq req, bool inactivated,
+									 KeyJoinInactiveReason reason,
+									 Oid origin_view,
+									 const char *referencing_relcols,
+									 const char *referenced_relcols,
+									 const char *referencing_relation,
+									 const char *referenced_relation,
+									 const char *join_name);
+static void key_join_report_failure(ParseState *pstate, ParseLoc location,
+									JoinType jointype,
+									const KeyJoinMatch *match,
+									const KeyJoinFailureSide *referencing,
+									const KeyJoinFailureSide *referenced);
+
+/*
+ * transformAndValidateKeyJoin
+ *
+ *		Transform raw key-join syntax into proven join quals and a
+ *		KeyJoinNode.
+ *
+ *		This resolves the named columns, ensures operand RTEs expose proof
+ *		facts, proves the key join, and installs strict equality quals.
+ */
+void
+transformAndValidateKeyJoin(ParseState *pstate, JoinExpr *j,
+							ParseNamespaceItem *l_nsitem,
+							ParseNamespaceItem *r_nsitem,
+							List *l_namespace)
+{
+	KeyJoinClause *key_clause = castNode(KeyJoinClause, j->keyJoin);
+	ParseNamespaceItem *ref_nsitem = NULL;
+	bool		local_is_referencing = (key_clause->direction == KEY_JOIN_RIGHT_ARROW);
+	bool		referencing_left = !local_is_referencing;
+	ParseNamespaceItem *fk_surface = local_is_referencing ?
+		r_nsitem : l_nsitem;
+	ParseNamespaceItem *pk_surface = local_is_referencing ?
+		l_nsitem : r_nsitem;
+	KeyJoinColumn *local_cols;
+	KeyJoinColumn *ref_cols;
+	List	   *referencing_attnums = NIL;
+	List	   *referenced_attnums = NIL;
+	List	   *ref_alias_attnums = NIL;
+	List	   *referencing_vars = NIL;
+	List	   *referenced_vars = NIL;
+	KeyJoinFactContext fk_context = {.pstate = pstate};
+	KeyJoinFactContext pk_context = {.pstate = pstate};
+	KeyJoinMatch match;
+	KeyJoinNode *key_join;
+	int			ncols = list_length(key_clause->localCols);
+
+	if (ncols != list_length(key_clause->refCols))
+		ereport(ERROR,
+				(errcode(ERRCODE_INVALID_FOREIGN_KEY),
+				 errmsg("key join column lists must have the same length"),
+				 parser_errposition(pstate, key_clause->location)));
+
+	/* The key-join alias must name exactly one visible relation on the left. */
+	foreach_ptr(ParseNamespaceItem, nsitem, l_namespace)
+	{
+		Assert(nsitem->p_names != NULL);
+		if (!nsitem->p_rel_visible ||
+			strcmp(nsitem->p_names->aliasname, key_clause->refAlias) != 0)
+			continue;
+		if (ref_nsitem != NULL)
+			ereport(ERROR,
+					(errcode(ERRCODE_AMBIGUOUS_ALIAS),
+					 errmsg("table reference \"%s\" is ambiguous",
+							key_clause->refAlias),
+					 parser_errposition(pstate, key_clause->location)));
+		ref_nsitem = nsitem;
+	}
+	if (ref_nsitem == NULL)
+		ereport(ERROR,
+				(errcode(ERRCODE_UNDEFINED_TABLE),
+				 errmsg("key join alias \"%s\" is not present in the left join operand",
+						key_clause->refAlias),
+				 parser_errposition(pstate, key_clause->location)));
+
+	/*
+	 * Local columns are named on the right operand itself.  The arrow alias,
+	 * however, may name a visible item inside the left operand, so resolve
+	 * against that item and map onto the whole left operand surface.
+	 */
+	local_cols = resolve_columns_on_nsitem(pstate, r_nsitem, r_nsitem,
+										   key_clause->localCols,
+										   key_clause->location,
+										   local_is_referencing);
+	ref_cols = resolve_columns_on_nsitem(pstate, ref_nsitem, l_nsitem,
+										 key_clause->refCols,
+										 key_clause->location,
+										 !local_is_referencing);
+
+	for (int i = 0; i < list_length(key_clause->localCols); i++)
+	{
+		Var		   *localvar = make_var_from_nscolumn(pstate,
+													  local_cols[i].nscol);
+		Var		   *refvar = make_var_from_nscolumn(pstate, ref_cols[i].nscol);
+		KeyJoinColumn *fkcol = local_is_referencing ?
+			&local_cols[i] : &ref_cols[i];
+		KeyJoinColumn *pkcol = local_is_referencing ?
+			&ref_cols[i] : &local_cols[i];
+
+		ref_alias_attnums = lappend_int(ref_alias_attnums,
+										ref_cols[i].nsattno);
+		referencing_attnums = lappend_int(referencing_attnums,
+										  fkcol->surface_attno);
+		referenced_attnums = lappend_int(referenced_attnums,
+										 pkcol->surface_attno);
+		referencing_vars = lappend(referencing_vars,
+								   local_is_referencing ? localvar : refvar);
+		referenced_vars = lappend(referenced_vars,
+								  local_is_referencing ? refvar : localvar);
+	}
+
+	ensure_key_join_surface_facts(&fk_context, fk_surface->p_rte);
+
+	ensure_key_join_surface_facts(&pk_context, pk_surface->p_rte);
+
+	/*
+	 * Deliberately ignore j->joinFilter here: the proof is about the two
+	 * operand surfaces before any join-local FILTER is applied.
+	 */
+	if (!find_key_join_match(fk_surface->p_rte, pk_surface->p_rte,
+							 referencing_attnums, referenced_attnums,
+							 !join_preserves_side(j->jointype, referencing_left),
+							 &match))
+	{
+		KeyJoinFailureSide referencing = {0};
+		KeyJoinFailureSide referenced = {0};
+
+		referencing.alias = local_is_referencing ?
+			r_nsitem->p_names->aliasname : ref_nsitem->p_names->aliasname;
+		referencing.columns = local_is_referencing ? local_cols : ref_cols;
+		referencing.ncolumns = ncols;
+		referenced.alias = local_is_referencing ?
+			ref_nsitem->p_names->aliasname : r_nsitem->p_names->aliasname;
+		referenced.columns = local_is_referencing ? ref_cols : local_cols;
+		referenced.ncolumns = ncols;
+		key_join_report_failure(pstate, key_clause->location, j->jointype,
+								&match, &referencing, &referenced);
+	}
+
+	INJECTION_POINT("key-join-after-proof-match", NULL);
+
+	/* Install the equality quals proven by condition 2. */
+	j->quals = build_key_join_quals(referenced_vars, referencing_vars,
+									match.eqoperators, match.eqtypes,
+									match.eqtypmods, NIL,
+									key_clause->location);
+
+	key_join = makeNode(KeyJoinNode);
+	key_join->direction = key_clause->direction;
+	key_join->referencingVarno = fk_surface->p_rtindex;
+	key_join->referencedVarno = pk_surface->p_rtindex;
+	key_join->referencingAttnums = referencing_attnums;
+	key_join->referencedAttnums = referenced_attnums;
+	key_join->refAliasVarno = ref_nsitem->p_rtindex;
+	key_join->refAliasAttnums = ref_alias_attnums;
+	key_join->constraint = match.constraint;
+	key_join->notNullConstraints = match.notnulldeps;
+	key_join->proofDependencies = match.proofdeps;
+
+	j->keyJoin = (Node *) key_join;
+}
+
+/*
+ * resolve_columns_on_nsitem
+ *
+ *		Resolve key-join column names against an operand namespace item.
+ *
+ *		Reject names that are missing, ambiguous, or not exposed by the
+ *		operand surface.
+ */
+static KeyJoinColumn *
+resolve_columns_on_nsitem(ParseState *pstate,
+						  ParseNamespaceItem *lookup,
+						  ParseNamespaceItem *surface, List *names,
+						  ParseLoc location, bool is_referencing)
+{
+	int			ncols = list_length(names);
+	int			surface_ncols = list_length(surface->p_names->colnames);
+	KeyJoinColumn *cols = palloc0_array(KeyJoinColumn, ncols);
+	int			i = 0;
+
+	foreach_ptr(Node, namenode, names)
+	{
+		char	   *name = strVal(namenode);
+		int			attno = 0;
+		int			match = 0;
+
+		foreach_ptr(Node, cnnode, lookup->p_names->colnames)
+		{
+			attno++;
+			if (strcmp(strVal(cnnode), name) == 0)
+			{
+				cols[i].name = name;
+				cols[i].nsitem = lookup;
+				cols[i].nsattno = attno;
+				cols[i].nscol = lookup->p_nscolumns + attno - 1;
+				match++;
+			}
+		}
+		if (match == 0)
+			ereport(ERROR,
+					(errcode(ERRCODE_UNDEFINED_COLUMN),
+					 is_referencing ?
+					 errmsg("referencing column \"%s\" does not exist", name) :
+					 errmsg("referenced column \"%s\" does not exist", name),
+					 parser_errposition(pstate, location)));
+		if (match > 1)
+			ereport(ERROR,
+					(errcode(ERRCODE_AMBIGUOUS_COLUMN),
+					 is_referencing ?
+					 errmsg("referencing column \"%s\" is ambiguous", name) :
+					 errmsg("referenced column \"%s\" is ambiguous", name),
+					 parser_errposition(pstate, location)));
+
+		/* Find the operand surface attnum for the resolved namespace column. */
+		for (int j = 0; j < surface_ncols; j++)
+		{
+			ParseNamespaceColumn *scol = surface->p_nscolumns + j;
+
+			if (scol->p_varno == cols[i].nscol->p_varno &&
+				scol->p_varattno == cols[i].nscol->p_varattno)
+			{
+				cols[i].surface_attno = j + 1;
+				break;
+			}
+		}
+
+		if (cols[i].surface_attno == 0)
+			ereport(ERROR,
+					(errcode(ERRCODE_INVALID_COLUMN_REFERENCE),
+					 is_referencing ?
+					 errmsg("referencing column \"%s\" is not exposed by the join operand",
+							name) :
+					 errmsg("referenced column \"%s\" is not exposed by the join operand",
+							name),
+					 parser_errposition(pstate, location)));
+		i++;
+	}
+	return cols;
+}
+
+/*
+ * make_var_from_nscolumn
+ *
+ *		Build a Var for a generated key-join qual and mark nullable state
+ *		and column read privilege.
+ */
+static Var *
+make_var_from_nscolumn(ParseState *pstate, ParseNamespaceColumn *nscol)
+{
+	Var		   *var = makeVar(nscol->p_varno, nscol->p_varattno,
+							  nscol->p_vartype, nscol->p_vartypmod,
+							  nscol->p_varcollid, 0);
+
+	var->varreturningtype = nscol->p_varreturningtype;
+	var->varnosyn = nscol->p_varnosyn;
+	var->varattnosyn = nscol->p_varattnosyn;
+	markNullableIfNeeded(pstate, var);
+	markVarForSelectPriv(pstate, var);
+	return var;
+}
+
+/*
+ * join_preserves_side
+ *
+ *		Return true if the join type preserves rows from the requested side.
+ */
+static bool
+join_preserves_side(JoinType jointype, bool leftside)
+{
+	return jointype == JOIN_FULL ||
+		(leftside ? jointype == JOIN_LEFT : jointype == JOIN_RIGHT);
+}
+
+/*
+ * build_key_join_quals
+ *
+ *		Build the executable equality quals for a proven key join.
+ */
+static Node *
+build_key_join_quals(List *referenced_args,
+					 List *referencing_args, List *eqoperators,
+					 List *eqtypes, List *eqtypmods,
+					 List *locations, ParseLoc default_location)
+{
+	List	   *quals = NIL;
+	ListCell   *lcrpk;
+	ListCell   *lcrfk;
+	ListCell   *lcop;
+	ListCell   *lctype;
+	ListCell   *lctypmod;
+	ListCell   *lcloc;
+
+	Assert(list_length(referenced_args) == list_length(referencing_args));
+	Assert(list_length(referenced_args) == list_length(eqoperators));
+	Assert(list_length(referenced_args) == list_length(eqtypes));
+	Assert(list_length(referenced_args) == list_length(eqtypmods));
+	Assert(locations == NIL ||
+		   list_length(locations) == list_length(eqoperators));
+
+	lcloc = list_head(locations);
+	forfive(lcrpk, referenced_args, lcrfk, referencing_args,
+			lcop, eqoperators, lctype, eqtypes, lctypmod, eqtypmods)
+	{
+		Node	   *pkarg = (Node *) lfirst(lcrpk);
+		Node	   *fkarg = (Node *) lfirst(lcrfk);
+		Oid			opno = lfirst_oid(lcop);
+		Oid			eqtype = lfirst_oid(lctype);
+		int32		eqtypmod = lfirst_int(lctypmod);
+		ParseLoc	location = default_location;
+		Oid			opfuncid;
+#ifdef USE_ASSERT_CHECKING
+		Oid			lefttype;
+		Oid			righttype;
+#endif
+		OpExpr	   *result;
+
+		if (locations != NIL)
+		{
+			location = lfirst_int(lcloc);
+			lcloc = lnext(locations, lcloc);
+		}
+
+		/*
+		 * Build a key-join equality OpExpr from a catalog operator OID.  For
+		 * domains, the proof identity remains the domain type, while the
+		 * executable equality operator is proven and run against the base type.
+		 * The proof matcher only passes operators checked against that
+		 * normalized equality-input identity and known to be strict,
+		 * non-set-returning boolean equality.
+		 */
+		opfuncid = get_opcode(opno);
+		pkarg = applyRelabelType(pkarg, eqtype, eqtypmod,
+								 exprCollation(pkarg),
+								 COERCE_IMPLICIT_CAST, -1, false);
+		fkarg = applyRelabelType(fkarg, eqtype, eqtypmod,
+								 exprCollation(fkarg),
+								 COERCE_IMPLICIT_CAST, -1, false);
+#ifdef USE_ASSERT_CHECKING
+		op_input_types(opno, &lefttype, &righttype);
+#endif
+		Assert(RegProcedureIsValid(opfuncid));
+		Assert(lefttype == exprType(pkarg));
+		Assert(righttype == exprType(fkarg));
+		Assert(get_op_rettype(opno) == BOOLOID);
+		Assert(exprTypmod(pkarg) == exprTypmod(fkarg));
+		Assert(exprCollation(pkarg) == exprCollation(fkarg));
+		Assert(!get_func_retset(opfuncid));
+		Assert(func_strict(opfuncid));
+
+		result = makeNode(OpExpr);
+		result->opno = opno;
+		result->opfuncid = opfuncid;
+		result->opresulttype = BOOLOID;
+		result->opretset = false;
+		result->opcollid = InvalidOid;
+		result->inputcollid = exprCollation(pkarg);
+		result->args = list_make2(pkarg, fkarg);
+		result->location = location;
+
+		quals = lappend(quals, result);
+	}
+
+	return (list_length(quals) == 1) ? linitial(quals) :
+		(Node *) makeBoolExpr(AND_EXPR, quals, -1);
+}
+
+/*
+ * find_key_join_match
+ *
+ *		Find proof facts that validate one key join.
+ *
+ * The proof has three pieces:
+ *	  1. The referenced side has a unique fact covering the selected columns.
+ *	  2. The referencing side has a foreign-key fact pointing at that unique
+ *		 fact, and any filter quals on the referenced side remap into matching
+ *		 filter quals on the referencing side.
+ *	  3. If the join type does not preserve the referencing side, each
+ *		 referencing column has not-null evidence.
+ *
+ * On success, *match receives the selected constraint, equality operators
+ * and equality-input identities in key-join column order, and the
+ * accumulated dependency lists.  On failure, *match receives diagnostic
+ * fields describing the first unmet proof requirement.
+ */
+static bool
+find_key_join_match(RangeTblEntry *referencing_rte,
+					RangeTblEntry *referenced_rte,
+					List *referencing_attnums,
+					List *referenced_attnums,
+					bool need_notnull,
+					KeyJoinMatch *match)
+{
+	KeyJoinSurfaceFacts *rfacts;
+	KeyJoinSurfaceFacts *pfacts;
+	KeyJoinReq	reached = REQ_NONE;
+	KeyJoinFact *inactive_unique = NULL;
+	KeyJoinFact *inactive_coverage = NULL;
+	KeyJoinFact *inactive_notnull = NULL;
+
+	Assert(match != NULL);
+	Assert(referencing_rte != NULL);
+	Assert(referenced_rte != NULL);
+
+	/* Diagnostics default to "missing FK"; the failure tail refines this. */
+	match->failReq = REQ_FK;
+	match->failInactivated = false;
+	match->failReason = KJI_NONE;
+	match->failOriginView = InvalidOid;
+
+	if (referenced_rte->tablesample != NULL)
+		return false;
+
+	Assert(referencing_rte->keyJoinFactsComputed);
+	Assert(referenced_rte->keyJoinFactsComputed);
+
+	if (referencing_rte->keyJoinFacts == NULL ||
+		referenced_rte->keyJoinFacts == NULL)
+		return false;
+
+	rfacts = referencing_rte->keyJoinFacts;
+	pfacts = referenced_rte->keyJoinFacts;
+
+	/* ---- Candidate 1: pick an FK fact on the referencing side ---- */
+	foreach_node(KeyJoinFact, fkfact, rfacts->facts)
+	{
+		List	   *referencing_base;
+
+		if (fkfact->kind != KJF_FOREIGN_KEY)
+			continue;
+		Assert(fkfact->active);
+
+		Assert(list_length(fkfact->keyPositions) ==
+			   list_length(fkfact->baseAttnums));
+		Assert(list_length(fkfact->baseAttnums) ==
+			   list_length(fkfact->referencedAttnums));
+
+		if (!select_key_position_parts(referencing_attnums,
+									   fkfact->keyPositions,
+									   fkfact->baseAttnums,
+									   &referencing_base, NULL))
+			continue;
+		if (reached < REQ_FK)
+			reached = REQ_FK;
+
+		/* ---- Candidate 2: pick a unique fact on the referenced side ---- */
+		foreach_node(KeyJoinFact, uniqfact, pfacts->facts)
+		{
+			List	   *unique_base;
+			List	   *unique_key_positions;
+			bool		catalog_unique;
+
+			if (uniqfact->kind != KJF_UNIQUE)
+				continue;
+
+			Assert(list_length(uniqfact->keyPositions) ==
+				   list_length(uniqfact->baseAttnums));
+
+			if (!select_key_position_parts(referenced_attnums,
+										   uniqfact->keyPositions,
+										   uniqfact->baseAttnums,
+										   &unique_base,
+										   &unique_key_positions))
+				continue;
+			Assert(int_lists_same_members(uniqfact->baseAttnums, unique_base));
+			if (!uniqfact->active)
+			{
+				if (inactive_unique == NULL)
+					inactive_unique = uniqfact;
+				continue;
+			}
+			catalog_unique = OidIsValid(uniqfact->relid);
+
+			/*
+			 * A catalog-backed unique fact has to be on the same relation the
+			 * FK fact targets; without that, condition 1 doesn't hold over
+			 * the relevant rows.
+			 */
+			if (catalog_unique &&
+				uniqfact->relid != fkfact->referencedRelid)
+				continue;
+			if (reached < REQ_UNIQUE)
+				reached = REQ_UNIQUE;
+
+			/* ---- Candidate 3: pick a row-coverage fact ---- */
+			foreach_node(KeyJoinFact, coverage, pfacts->facts)
+			{
+				/*
+				 * Per-coverage scratch.  These live only inside this
+				 * candidate; if any check below rejects it, the next
+				 * coverage candidate starts fresh.
+				 */
+				List	   *coverage_base;
+				List	   *coverage_key_positions;
+				List	   *fk_key_positions = NIL;
+				List	   *fk_eqoperators = NIL;
+				List	   *fk_eqtypes = NIL;
+				List	   *fk_eqtypmods = NIL;
+				List	   *local_notnulldeps = NIL;
+
+				if (coverage->kind != KJF_ROW_COVERAGE)
+					continue;
+
+				Assert(list_length(coverage->keyPositions) ==
+					   list_length(coverage->baseAttnums));
+
+				if (!select_key_position_parts(referenced_attnums,
+											   coverage->keyPositions,
+											   coverage->baseAttnums,
+											   &coverage_base,
+											   &coverage_key_positions))
+					continue;
+				if (!coverage->active)
+				{
+					if (inactive_coverage == NULL)
+						inactive_coverage = coverage;
+					continue;
+				}
+
+				/*
+				 * Matching row-coverage key positions for the selected
+				 * referenced columns must be rooted in the FK target. Fact
+				 * projection does not merge row-coverage identities across
+				 * base relations.
+				 */
+				if (coverage->relid != fkfact->referencedRelid)
+					continue;
+				Assert(!catalog_unique ||
+					   int_lists_same_members(coverage_base, unique_base));
+				Assert(int_lists_same_members(coverage->baseAttnums,
+											  coverage_base));
+				if (reached < REQ_COVERAGE)
+					reached = REQ_COVERAGE;
+
+				/* ---- Condition 2a: FK pairs match the selected columns ---- */
+				{
+					ListCell   *lcfkbase;
+					ListCell   *lcpkbase;
+					bool		fk_pairs_match = true;
+
+					/*
+					 * Key-join columns must cover the whole FK; a partial FK
+					 * match cannot prove containment for a multi-column key.
+					 * referencing_base and coverage_base are already in
+					 * key-join column order, so we drive the result lists
+					 * from them.
+					 */
+					Assert(list_length(referencing_base) ==
+						   list_length(fkfact->keyPositions));
+					Assert(list_length(coverage_base) ==
+						   list_length(fkfact->keyPositions));
+
+					forboth(lcfkbase, referencing_base,
+							lcpkbase, coverage_base)
+					{
+						int			fkbase = lfirst_int(lcfkbase);
+						int			pkbase = lfirst_int(lcpkbase);
+						int			fk_catalog_pos = -1;
+						ListCell   *lcfkatt;
+						ListCell   *lcrefatt;
+						KeyJoinKeyPosition *keypos;
+
+						/*
+						 * FK facts pair referencing and referenced base
+						 * attnums in catalog order.  Locate the pair for this
+						 * selected referencing column; the matching
+						 * referenced attnum must equal the column we selected
+						 * on the other side.
+						 */
+						forboth(lcfkatt, fkfact->baseAttnums,
+								lcrefatt, fkfact->referencedAttnums)
+						{
+							if (lfirst_int(lcfkatt) != fkbase)
+								continue;
+							if (lfirst_int(lcrefatt) != pkbase)
+							{
+								fk_pairs_match = false;
+								break;
+							}
+							fk_catalog_pos = foreach_current_index(lcfkatt);
+							break;
+						}
+						if (!fk_pairs_match)
+							break;
+
+						/*
+						 * referencing_base was selected from
+						 * fkfact->baseAttnums, so this lookup must find the
+						 * catalog position.
+						 */
+						Assert(fk_catalog_pos >= 0);
+
+						keypos = list_nth_node(KeyJoinKeyPosition,
+											   fkfact->keyPositions,
+											   fk_catalog_pos);
+						fk_key_positions = lappend(fk_key_positions, keypos);
+						fk_eqoperators = lappend_oid(fk_eqoperators,
+													 keypos->eqOperator);
+						fk_eqtypes = lappend_oid(fk_eqtypes,
+												 keypos->eqTypeOid);
+						fk_eqtypmods = lappend_int(fk_eqtypmods,
+												   keypos->eqTypmod);
+					}
+					if (!fk_pairs_match)
+						continue;
+				}
+				if (reached < REQ_FKPAIR)
+					reached = REQ_FKPAIR;
+
+				/*
+				 * ---- Condition 2b: unique + coverage agree on identity ----
+				 *
+				 * A relation can expose multiple usable unique indexes on the
+				 * same column list.  Only the unique and row-coverage facts
+				 * whose key identity matches the FK key identity can prove
+				 * this key join.  Identity mismatches are normal candidate
+				 * misses, not separately reportable user-facing failures.
+				 */
+				if (!key_position_identity_lists_equal(unique_key_positions,
+													   fk_key_positions))
+					continue;
+				if (!key_position_identity_lists_equal(coverage_key_positions,
+													   fk_key_positions))
+					continue;
+
+				/* ---- Condition 2c: referenced filters remap into FK ---- */
+				if (coverage->filterConjuncts != NIL)
+				{
+					List	   *position_map;
+					bool		filters_match = true;
+
+					position_map =
+						make_filter_position_map(coverage->baseAttnums,
+												 coverage_base,
+												 fkfact->baseAttnums,
+												 referencing_base);
+
+					foreach_ptr(Node, needed, coverage->filterConjuncts)
+					{
+						Node	   *remapped;
+
+						/*
+						 * The selected key-join columns cover both complete
+						 * key lists, so every canonical coverage filter has
+						 * a target FK key position.
+						 */
+						Assert(filter_conjunct_can_remap(needed,
+														 position_map));
+						remapped = remap_filter_conjunct(needed, position_map);
+						Assert(filter_conjunct_matches_key_positions(remapped,
+																	 fkfact->keyPositions));
+						if (!list_contains_equal_node(fkfact->filterConjuncts,
+													  remapped))
+						{
+							filters_match = false;
+							break;
+						}
+					}
+					if (!filters_match)
+						continue;
+				}
+				if (reached < REQ_FILTER)
+					reached = REQ_FILTER;
+
+				/* ---- Condition 3: not-null evidence on referencing side ---- */
+				if (need_notnull)
+				{
+					bool		notnull_match = true;
+
+					foreach_int(attno, referencing_attnums)
+					{
+						bool		found = false;
+
+						foreach_node(KeyJoinFact, fact, rfacts->facts)
+						{
+							if (fact->kind != KJF_NOT_NULL)
+								continue;
+							if (fact->attnum != attno)
+								continue;
+							if (!fact->active)
+							{
+								if (inactive_notnull == NULL)
+									inactive_notnull = fact;
+								continue;
+							}
+							local_notnulldeps =
+								append_dependencies_unique(local_notnulldeps,
+														   fact->dependencies);
+							found = true;
+							break;
+						}
+						if (!found)
+						{
+							notnull_match = false;
+							break;
+						}
+					}
+					if (!notnull_match)
+						continue;
+				}
+
+				/*
+				 * ---- Success: construct the result, exactly once ----
+				 *
+				 * Proof fields are filled only here.  proofdeps is built
+				 * locally before commit, so a partial accumulation cannot
+				 * leak into the result.
+				 */
+				{
+					List	   *proofdeps = NIL;
+
+					proofdeps = append_dependencies_unique(proofdeps,
+														   fkfact->dependencies);
+					proofdeps = append_dependencies_unique(proofdeps,
+														   uniqfact->dependencies);
+					proofdeps = append_dependencies_unique(proofdeps,
+														   coverage->dependencies);
+					proofdeps = append_dependencies_unique(proofdeps,
+														   local_notnulldeps);
+
+					match->constraint = fkfact->constraint;
+					match->eqoperators = fk_eqoperators;
+					match->eqtypes = fk_eqtypes;
+					match->eqtypmods = fk_eqtypmods;
+					match->notnulldeps = local_notnulldeps;
+					match->proofdeps = proofdeps;
+				}
+				return true;
+			}
+		}
+	}
+
+	/*
+	 * No proof.  Report the first unmet requirement: the deepest an active
+	 * candidate reached, plus one.  When a later requirement is a fact we
+	 * never found active, blame the inactive one stashed for it, if any.
+	 */
+	if (reached < REQ_FK)
+		match->failReq = REQ_FK;
+	else if (reached < REQ_UNIQUE)
+		match->failReq = REQ_UNIQUE;
+	else if (reached < REQ_COVERAGE)
+		match->failReq = REQ_COVERAGE;
+	else if (reached < REQ_FKPAIR)
+		match->failReq = REQ_FKPAIR;
+	else if (reached < REQ_FILTER)
+		match->failReq = REQ_FILTER;
+	else
+		match->failReq = REQ_NOTNULL;
+
+	{
+		KeyJoinFact *blame = NULL;
+
+		if (match->failReq == REQ_UNIQUE)
+			blame = inactive_unique;
+		else if (match->failReq == REQ_COVERAGE)
+			blame = inactive_coverage;
+		else if (match->failReq == REQ_NOTNULL)
+			blame = inactive_notnull;
+
+		if (blame != NULL)
+		{
+			match->failInactivated = true;
+			match->failReason = blame->inactiveReason;
+			match->failOriginView = blame->inactiveOriginView;
+		}
+	}
+
+	return false;
+}
+
+/*
+ * select_key_position_parts
+ *
+ *		Map selected surface attnums to base attnums and/or key positions.
+ *
+ *		Each selected attnum must identify a distinct key position.
+ */
+static bool
+select_key_position_parts(List *selected_attnums, List *keyPositions,
+						  List *baseAttnums, List **selected_base_attnums,
+						  List **selected_key_positions)
+{
+	List	   *base_result = NIL;
+	List	   *position_result = NIL;
+	List	   *used_positions = NIL;
+
+	if (list_length(selected_attnums) != list_length(keyPositions))
+		return false;
+	if (selected_base_attnums != NULL)
+	{
+		Assert(list_length(keyPositions) == list_length(baseAttnums));
+	}
+
+	foreach_int(attno, selected_attnums)
+	{
+		int			pos = key_position_index_for_attnum(keyPositions, attno);
+
+		if (pos < 0 || list_member_int(used_positions, pos))
+			return false;
+		used_positions = lappend_int(used_positions, pos);
+
+		if (selected_base_attnums != NULL)
+			base_result = lappend_int(base_result,
+									  list_nth_int(baseAttnums, pos));
+		if (selected_key_positions != NULL)
+			position_result = lappend(position_result,
+									  list_nth(keyPositions, pos));
+	}
+
+	if (selected_base_attnums != NULL)
+		*selected_base_attnums = base_result;
+	if (selected_key_positions != NULL)
+		*selected_key_positions = position_result;
+	return true;
+}
+
+/*
+ * rowcollapse_preserves_rowcoverage
+ *
+ *		Return true if a GROUP BY or DISTINCT key preserves one existing
+ *		row-coverage fact.
+ *
+ *		Every projected row-coverage key position must appear in the
+ *		grouped/distinct key under the same identity.  Nullable referenced
+ *		rows are excluded from the referenced multiset, so collapsing
+ *		null-containing referenced rows cannot remove a value required by an
+ *		all-non-null referencing key.  Extra row-collapse columns do not merge
+ *		distinct covered key values.
+ */
+static bool
+rowcollapse_preserves_rowcoverage(List *rowcoverage_key_positions,
+								  List *rowcollapse_key_positions)
+{
+	List	   *used_row_positions = NIL;
+
+	foreach_node(KeyJoinKeyPosition, keypos, rowcoverage_key_positions)
+	{
+		bool		position_match = false;
+		int			pos = 0;
+
+		foreach_node(KeyJoinKeyPosition, rowpos, rowcollapse_key_positions)
+		{
+			if (list_member_int(used_row_positions, pos))
+			{
+				pos++;
+				continue;
+			}
+			if (!key_position_identity_equal(rowpos, keypos))
+			{
+				pos++;
+				continue;
+			}
+			foreach_int(attno, rowpos->attnums)
+			{
+				if (list_member_int(keypos->attnums, attno))
+				{
+					position_match = true;
+					break;
+				}
+			}
+			if (position_match)
+			{
+				used_row_positions = lappend_int(used_row_positions, pos);
+				break;
+			}
+			pos++;
+		}
+		if (!position_match)
+			return false;
+	}
+
+	return true;
+}
+
+static int
+key_position_index_for_attnum(List *keyPositions, int attno)
+{
+	int			match = -1;
+
+	foreach_node(KeyJoinKeyPosition, keypos, keyPositions)
+	{
+		if (list_member_int(keypos->attnums, attno))
+		{
+			if (match >= 0)
+				return -1;
+			match = foreach_current_index(keypos);
+		}
+	}
+	return match;
+}
+
+/*
+ * key_position_identity_lists_equal
+ *
+ *		Return true if two key-position lists have matching key identities.
+ */
+static bool
+key_position_identity_lists_equal(List *left, List *right)
+{
+	ListCell   *lcleft;
+	ListCell   *lcright;
+
+	Assert(list_length(left) == list_length(right));
+	forboth(lcleft, left, lcright, right)
+	{
+		if (!key_position_identity_equal(lfirst_node(KeyJoinKeyPosition, lcleft),
+										 lfirst_node(KeyJoinKeyPosition, lcright)))
+			return false;
+	}
+	return true;
+}
+
+/*
+ * key_position_identity_equal
+ *
+ *		Return true if two key positions have the same type/collation/op
+ *		identity.
+ */
+static bool
+key_position_identity_equal(KeyJoinKeyPosition *left, KeyJoinKeyPosition *right)
+{
+	if (left->typeOid != right->typeOid)
+		return false;
+	if (left->typmod != right->typmod)
+		return false;
+	if (left->collationOid != right->collationOid)
+		return false;
+	if (left->eqTypeOid != right->eqTypeOid)
+		return false;
+	Assert(left->eqTypmod == right->eqTypmod);
+	return left->eqOperator == right->eqOperator;
+}
+
+static bool
+int_lists_same_members(List *a, List *b)
+{
+	if (list_length(a) != list_length(b))
+		return false;
+	foreach_int(value, a)
+	{
+		if (!list_member_int(b, value))
+			return false;
+	}
+	return true;
+}
+
+/*
+ * make_filter_position_map
+ *
+ *		Build a Param-position map between source and target key lists.
+ *
+ *		Entries of -1 mark source filters outside the selected key join.
+ */
+static List *
+make_filter_position_map(List *src_base_attnums, List *src_selected_base,
+						 List *dst_base_attnums, List *dst_selected_base)
+{
+	List	   *result = NIL;
+
+	Assert(list_length(src_selected_base) == list_length(dst_selected_base));
+
+	foreach_int(srcbase, src_base_attnums)
+	{
+		int			dstpos = -1;
+		ListCell   *lcsrc;
+		ListCell   *lcdst;
+
+		forboth(lcsrc, src_selected_base, lcdst, dst_selected_base)
+		{
+			if (lfirst_int(lcsrc) == srcbase)
+			{
+				int			dstbase = lfirst_int(lcdst);
+
+				foreach_int(baseattno, dst_base_attnums)
+				{
+					if (baseattno == dstbase)
+					{
+						dstpos = foreach_current_index(baseattno);
+						break;
+					}
+				}
+				break;
+			}
+		}
+		result = lappend_int(result, dstpos);
+	}
+	return result;
+}
+
+/*
+ * remap_filter_conjunct
+ *
+ *		Remap proof-filter Params through a position map.
+ */
+static Node *
+remap_filter_conjunct(Node *conjunct, List *position_map)
+{
+	FKFilterRemapContext context = {.position_map = position_map};
+
+	return remap_filter_param_mutator(conjunct, &context);
+}
+
+/*
+ * filter_conjunct_can_remap
+ *
+ *		Return true if every proof-filter Param used by this conjunct has a
+ *		target key position in the supplied map.
+ */
+static bool
+filter_conjunct_can_remap(Node *conjunct, List *position_map)
+{
+	FKFilterRemapContext context = {.position_map = position_map};
+
+	return !filter_conjunct_unremappable_param_walker(conjunct, &context);
+}
+
+/*
+ * remap_filter_param_mutator
+ *
+ *		Mutator callback for remapping key-join proof filter Params.
+ */
+static Node *
+remap_filter_param_mutator(Node *node, void *context_arg)
+{
+	FKFilterRemapContext *context = (FKFilterRemapContext *) context_arg;
+
+	if (node == NULL)
+		return NULL;
+
+	if (IsA(node, Param))
+	{
+		Param	   *param = castNode(Param, node);
+		Param	   *newparam;
+		int			oldpos;
+		int			newpos;
+
+		Assert(param->paramkind == PARAM_KEYJOIN);
+
+		/*
+		 * paramid is a 1-based position into the key-position list that the
+		 * caller already sized.  These two conditions are therefore
+		 * programmer invariants, not user-reachable errors.
+		 */
+		oldpos = param->paramid - 1;
+		Assert(oldpos >= 0);
+		Assert(oldpos < list_length(context->position_map));
+
+		newpos = list_nth_int(context->position_map, oldpos);
+		Assert(newpos >= 0);
+		newparam = copyObject(param);
+		newparam->paramid = newpos + 1;
+		return (Node *) newparam;
+	}
+
+	return expression_tree_mutator(node, remap_filter_param_mutator, context);
+}
+
+/*
+ * filter_conjunct_unremappable_param_walker
+ *
+ *		Walker callback for finding proof-filter Params that cannot remap.
+ */
+static bool
+filter_conjunct_unremappable_param_walker(Node *node, void *context_arg)
+{
+	FKFilterRemapContext *context = (FKFilterRemapContext *) context_arg;
+
+	Assert(node != NULL);
+
+	if (IsA(node, Param))
+	{
+		Param	   *param = castNode(Param, node);
+		int			oldpos;
+
+		Assert(param->paramkind == PARAM_KEYJOIN);
+
+		oldpos = param->paramid - 1;
+		Assert(oldpos >= 0);
+		Assert(oldpos < list_length(context->position_map));
+		return list_nth_int(context->position_map, oldpos) < 0;
+	}
+
+	return expression_tree_walker(node,
+								  filter_conjunct_unremappable_param_walker,
+								  context);
+}
+
+/*
+ * filter_conjunct_matches_key_positions
+ *
+ *		Check that a key-join proof filter matches key identity.
+ */
+static bool
+filter_conjunct_matches_key_positions(Node *conjunct, List *keyPositions)
+{
+	OpExpr	   *op;
+	Param	   *param;
+	KeyJoinKeyPosition *keypos;
+	int			pos;
+#ifdef USE_ASSERT_CHECKING
+	Node	   *value;
+#endif
+
+	Assert(conjunct != NULL);
+	op = castNode(OpExpr, conjunct);
+	Assert(list_length(op->args) == 2);
+	param = castNode(Param, linitial(op->args));
+	Assert(param->paramkind == PARAM_KEYJOIN);
+
+	pos = param->paramid - 1;
+	Assert(pos >= 0);
+	Assert(pos < list_length(keyPositions));
+	keypos = list_nth_node(KeyJoinKeyPosition, keyPositions, pos);
+
+	/*
+	 * add_filter_conjuncts() stores only canonical key = value filters.
+	 * Remapping changes only Param ids; the original filter operator and
+	 * value expression must remain compatible with the destination key.
+	 */
+	Assert(param->paramtype == keypos->eqTypeOid);
+	Assert(param->paramtypmod == keypos->eqTypmod);
+	Assert(param->paramcollid == keypos->collationOid);
+	if (!filter_operator_matches_key_position(op, keypos, false))
+		return false;
+
+#ifdef USE_ASSERT_CHECKING
+	value = lsecond(op->args);
+	Assert(filter_value_allowed(value));
+#endif
+	return true;
+}
+
+/*
+ * filter_value_allowed
+ *
+ *		Return true if a filter value can be stored in proof-filter form.
+ *		Proof filters use a strict allowlist: constants, SQL value functions,
+ *		non-volatile scalar functions, and transparent type/collation wrappers
+ *		whose arguments are also allowed.  The full after-planning volatility
+ *		check runs on the canonical filter after locking its dependencies.
+ */
+static bool
+filter_value_allowed(Node *node)
+{
+	Assert(node != NULL);
+
+	if (IsA(node, Const))
+		return true;
+
+	if (IsA(node, SQLValueFunction))
+		return true;
+
+	if (IsA(node, FuncExpr))
+	{
+		FuncExpr   *expr = castNode(FuncExpr, node);
+
+		Assert(!expr->funcretset);
+		if (func_volatile(expr->funcid) == PROVOLATILE_VOLATILE)
+			return false;
+		foreach_ptr(Node, arg, expr->args)
+		{
+			if (!filter_value_allowed(arg))
+				return false;
+		}
+		return true;
+	}
+
+	if (IsA(node, RelabelType))
+		return filter_value_allowed((Node *) castNode(RelabelType, node)->arg);
+
+	if (IsA(node, CoerceViaIO))
+		return filter_value_allowed((Node *) castNode(CoerceViaIO, node)->arg);
+
+	if (IsA(node, CollateExpr))
+		return filter_value_allowed((Node *) castNode(CollateExpr, node)->arg);
+
+	return false;
+}
+
+/*
+ * make_canonical_filter_conjunct
+ *
+ *		Build the stored proof-filter representation for a direct key filter.
+ */
+static Node *
+make_canonical_filter_conjunct(KeyJoinKeyPosition *keypos, int pos, OpExpr *op,
+							   Node *value)
+{
+	OpExpr	   *newop;
+
+	Assert(op != NULL);
+	Assert(value != NULL);
+	Assert(filter_value_allowed(value));
+	Assert(filter_operator_matches_key_position(op, keypos, false));
+
+	newop = copyObject(op);
+	set_opfuncid(newop);
+	Assert(OidIsValid(newop->opfuncid));
+	newop->args = list_make2(make_filter_param(keypos, pos),
+							 copyObject(value));
+	newop->location = -1;
+	return (Node *) newop;
+}
+
+/*
+ * filter_operator_matches_key_position
+ *
+ *		Check whether a direct key-filter operator is compatible with keypos.
+ */
+static bool
+filter_operator_matches_key_position(OpExpr *op, KeyJoinKeyPosition *keypos,
+									 bool lock_operator)
+{
+	Node	   *left;
+	Node	   *right;
+	RegProcedure funcid;
+	Oid			lefttype;
+	Oid			righttype;
+	Oid			rettype;
+	bool		result;
+
+	Assert(op != NULL);
+	Assert(keypos != NULL);
+	Assert(list_length(op->args) == 2);
+
+	left = linitial(op->args);
+	right = lsecond(op->args);
+
+	Assert(OidIsValid(op->opno));
+
+	if (lock_operator)
+	{
+		HeapTuple	optup;
+		HeapTuple	proctup;
+		Form_pg_operator opform;
+		Form_pg_proc procform;
+
+		optup = lock_and_fetch_key_join_operator(op->opno);
+		opform = (Form_pg_operator) GETSTRUCT(optup);
+		funcid = opform->oprcode;
+		lefttype = opform->oprleft;
+		righttype = opform->oprright;
+		rettype = opform->oprresult;
+		ReleaseSysCache(optup);
+
+		Assert(OidIsValid(funcid));
+
+		proctup = lock_and_fetch_key_join_proc((Oid) funcid);
+		procform = (Form_pg_proc) GETSTRUCT(proctup);
+		Assert(!procform->proretset);
+		result = procform->proisstrict &&
+			procform->provolatile == PROVOLATILE_IMMUTABLE;
+		ReleaseSysCache(proctup);
+	}
+	else
+	{
+		funcid = get_opcode(op->opno);
+		op_input_types(op->opno, &lefttype, &righttype);
+		rettype = get_op_rettype(op->opno);
+		Assert(OidIsValid(funcid));
+		Assert(func_strict(funcid));
+		Assert(func_volatile(funcid) == PROVOLATILE_IMMUTABLE);
+		Assert(!get_func_retset(funcid));
+		result = true;
+	}
+
+	result &= lefttype == keypos->eqTypeOid;
+	result &= righttype == exprType(right);
+	result &= rettype == BOOLOID;
+	result &= !op->opretset;
+	result &= op->opresulttype == BOOLOID;
+	result &= exprType(left) == keypos->eqTypeOid;
+	result &= exprTypmod(left) == keypos->eqTypmod;
+	result &= exprCollation(left) == keypos->collationOid;
+	result &= filter_operator_is_compatible_equality(op->opno,
+													 keypos->eqOperator);
+	result &= collations_agree_on_equality(op->inputcollid,
+										   keypos->collationOid);
+
+	return result;
+}
+
+/*
+ * filter_operator_is_compatible_equality
+ *
+ *		Check whether opno is itself an equality operator compatible with keyop.
+ */
+static bool
+filter_operator_is_compatible_equality(Oid opno, Oid keyop)
+{
+	RegProcedure hash_proc;
+
+	if (opno == keyop)
+		return true;
+	if (!equality_ops_are_compatible(opno, keyop))
+		return false;
+
+	foreach_ptr(OpIndexInterpretation, interpretation,
+				get_op_index_interpretation(opno))
+	{
+		if (interpretation->cmptype != COMPARE_EQ)
+			continue;
+		if (op_in_opfamily(keyop, interpretation->opfamily_id))
+			return true;
+	}
+
+	return get_op_hash_functions(opno, &hash_proc, NULL);
+}
+
+/*
+ * key_join_contains_volatile_after_planning
+ *
+ *		Return true if a key-join proof expression or query contains
+ *		volatile functions after planner expression preprocessing.
+ *
+ * contain_volatile_functions_after_planning() accepts expressions, not whole
+ * Query trees.  Key-join proof code needs both forms, so handle Query nodes
+ * by walking each top-level expression subtree once.  A separate nested-Query
+ * walk restarts after-planning preprocessing inside subqueries, which
+ * expression_planner() deliberately does not descend into when called on a
+ * standalone expression.
+ */
+static bool
+key_join_contains_volatile_after_planning(Node *node)
+{
+	Assert(node != NULL);
+
+	if (IsA(node, Query))
+		return query_tree_walker(castNode(Query, node),
+								 key_join_after_planning_query_walker,
+								 NULL, 0);
+
+	return key_join_expression_contains_volatile_after_planning(node);
+}
+
+static bool
+key_join_expression_contains_volatile_after_planning(Node *node)
+{
+	Assert(node != NULL);
+	Assert(!IsA(node, Query));
+
+	if (contain_volatile_functions_after_planning((Expr *) node))
+		return true;
+
+	return expression_tree_walker(node, key_join_nested_query_walker, NULL);
+}
+
+static bool
+key_join_after_planning_query_walker(Node *node, void *context)
+{
+	if (node == NULL)
+		return false;
+
+	if (IsA(node, Query))
+		return key_join_contains_volatile_after_planning(node);
+
+	return key_join_expression_contains_volatile_after_planning(node);
+}
+
+static bool
+key_join_nested_query_walker(Node *node, void *context)
+{
+	if (node == NULL)
+		return false;
+
+	if (IsA(node, Query))
+		return key_join_contains_volatile_after_planning(node);
+
+	/*
+	 * key_join_expression_contains_volatile_after_planning() already checked
+	 * this whole expression subtree after planning.  Continue walking only so
+	 * that nested Query nodes get their own after-planning pass.
+	 */
+	return expression_tree_walker(node, key_join_nested_query_walker, context);
+}
+
+static bool
+list_contains_equal_node(List *list, Node *node)
+{
+	foreach_ptr(Node, oldnode, list)
+	{
+		if (equal(node, oldnode))
+			return true;
+	}
+	return false;
+}
+
+/*
+ * append_dependencies_unique
+ *
+ *		Append dependency entries from src to dst, suppressing duplicates.
+ */
+static List *
+append_dependencies_unique(List *dst, List *src)
+{
+	foreach_node(KeyJoinProofDependency, dep, src)
+	{
+		if (!dependency_member(dst, dep->classId, dep->objectId))
+			dst = lappend(dst, copyObject(dep));
+	}
+	return dst;
+}
+
+/*
+ * append_dependency_unique
+ *
+ *		Append one dependency entry if not already present.
+ */
+static List *
+append_dependency_unique(List *dependencies, Oid classId, Oid objectId)
+{
+	Assert(OidIsValid(objectId));
+
+	if (dependency_member(dependencies, classId, objectId))
+		return dependencies;
+	return lappend(dependencies, make_dependency(classId, objectId));
+}
+
+static bool
+dependency_member(List *deps, Oid classId, Oid objectId)
+{
+	foreach_node(KeyJoinProofDependency, dep, deps)
+	{
+		if (dep->classId != classId)
+			continue;
+		if (dep->objectId != objectId)
+			continue;
+		return true;
+	}
+	return false;
+}
+
+/*
+ * make_dependency
+ *
+ *		Build a KeyJoinProofDependency node for a whole-object dependency.
+ */
+static KeyJoinProofDependency *
+make_dependency(Oid classId, Oid objectId)
+{
+	KeyJoinProofDependency *dep = makeNode(KeyJoinProofDependency);
+
+	dep->classId = classId;
+	dep->objectId = objectId;
+	return dep;
+}
+
+/*
+ * append_dependency_to_facts
+ *
+ *		Mark every projected fact as depending on the same proof surface.
+ */
+static void
+append_dependency_to_facts(KeyJoinSurfaceFacts *facts,
+						   Oid classId, Oid objectId)
+{
+	if (facts == NULL)
+		return;
+
+	foreach_node(KeyJoinFact, fact, facts->facts)
+		fact->dependencies =
+			append_dependency_unique(fact->dependencies, classId, objectId);
+}
+
+/*
+ * lock_key_join_dependency_or_error
+ *
+ *		Lock one filter expression dependency with the same object-lock family
+ *		used by dependency deletion, then verify the locked object still
+ *		exists.  Filter expressions can depend on operators and functions only.
+ *		Relation and constraint proof dependencies are locked by their fact
+ *		builders before publication.
+ */
+static void
+lock_key_join_dependency_or_error(const KeyJoinProofDependency *dep)
+{
+	Assert(dep->classId == OperatorRelationId ||
+		   dep->classId == ProcedureRelationId);
+
+	if (dep->classId == OperatorRelationId)
+	{
+		HeapTuple	optup;
+
+		optup = lock_and_fetch_key_join_operator(dep->objectId);
+		ReleaseSysCache(optup);
+	}
+	else
+	{
+		HeapTuple	proctup;
+
+		Assert(dep->classId == ProcedureRelationId);
+		proctup = lock_and_fetch_key_join_proc(dep->objectId);
+		ReleaseSysCache(proctup);
+	}
+}
+
+/*
+ * lock_key_join_dependencies_or_error
+ *
+ *		Block until every dependency is locked and known to still exist.  Facts
+ *		may only publish dependencies that have passed through this helper or an
+ *		equivalent lock-and-fetch path.
+ */
+static void
+lock_key_join_dependencies_or_error(List *dependencies)
+{
+	INJECTION_POINT("key-join-before-filter-dependency-lock", NULL);
+
+	foreach_node(KeyJoinProofDependency, dep, dependencies)
+		lock_key_join_dependency_or_error(dep);
+}
+
+/*
+ * lock_and_fetch_key_join_constraint
+ *
+ *		Lock one constraint proof dependency, then return its syscache tuple.
+ *		The caller must release the tuple.
+ */
+static HeapTuple
+lock_and_fetch_key_join_constraint(Oid constraintOid)
+{
+	HeapTuple	contup;
+
+	Assert(OidIsValid(constraintOid));
+
+	LockDatabaseObject(ConstraintRelationId, constraintOid, 0,
+					   AccessShareLock);
+	contup = SearchSysCache1(CONSTROID, ObjectIdGetDatum(constraintOid));
+	Assert(HeapTupleIsValid(contup));
+	return contup;
+}
+
+/*
+ * lock_and_fetch_key_join_operator
+ *
+ *		Lock one operator proof dependency, then return its syscache tuple.
+ *		The caller must release the tuple.
+ */
+static HeapTuple
+lock_and_fetch_key_join_operator(Oid opno)
+{
+	HeapTuple	optup;
+
+	Assert(OidIsValid(opno));
+
+	LockDatabaseObject(OperatorRelationId, opno, 0, AccessShareLock);
+	optup = SearchSysCache1(OPEROID, ObjectIdGetDatum(opno));
+	if (!HeapTupleIsValid(optup))
+		ereport(ERROR,
+				(errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+				 errmsg("key join proof dependency operator was concurrently dropped")));
+	return optup;
+}
+
+/*
+ * lock_and_fetch_key_join_proc
+ *
+ *		Lock one function proof dependency, then return its syscache tuple.
+ *		The caller must release the tuple.
+ */
+static HeapTuple
+lock_and_fetch_key_join_proc(Oid procid)
+{
+	HeapTuple	proctup;
+
+	Assert(OidIsValid(procid));
+
+	LockDatabaseObject(ProcedureRelationId, procid, 0, AccessShareLock);
+	proctup = SearchSysCache1(PROCOID, ObjectIdGetDatum(procid));
+	if (!HeapTupleIsValid(proctup))
+		ereport(ERROR,
+				(errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+				 errmsg("key join proof dependency function was concurrently dropped")));
+	return proctup;
+}
+
+/*
+ * ensure_key_join_surface_facts
+ *
+ *		Key-join validation needs a demand-driven fact cache on each RTE it
+ *		inspects.  Some RTEs have no sound surface facts in this proof model
+ *		(for example, lateral subqueries, recursive CTEs, and ordinary
+ *		joins whose output cannot preserve input facts), so the cache also
+ *		records "computed, but no facts".
+ *
+ *		Keep that decision on a single path across live parse analysis, view
+ *		projection, and stored-query revalidation.  Those callers reach the
+ *		same RTE graph with different context available (ParseState for live
+ *		parsing, copied Query stacks for stored queries).
+ */
+static void
+ensure_key_join_surface_facts(KeyJoinFactContext *context, RangeTblEntry *rte)
+{
+	Assert(context != NULL);
+	Assert(rte != NULL);
+	Assert(!context->revalidating_stored_query ||
+		   (context->pstate == NULL &&
+			context->query != NULL &&
+			context->query_stack != NULL));
+
+	if (rte->keyJoinFactsComputed)
+		return;
+
+	Assert(rte->keyJoinFacts == NULL);
+
+	switch (rte->rtekind)
+	{
+		case RTE_RELATION:
+			{
+				/*
+				 * Live parser RTEs already hold their rellockmode lock from
+				 * range table construction, but stored-query revalidation and
+				 * view fact computation can reach this helper with only a
+				 * copied Query tree.  Hold this lock until transaction end so
+				 * DDL cannot change relation state or view definitions between
+				 * fact selection and stored dependency creation.
+				 */
+				Relation	rel = relation_open(rte->relid, AccessShareLock);
+
+				if (rte->relkind == RELKIND_VIEW)
+				{
+					/*
+					 * Views expose facts by projecting a revalidated copy of
+					 * their query.  Keep this query-projection path at the
+					 * RTE dispatcher rather than inside the catalog fact
+					 * collector for base relations.
+					 */
+					Query	   *viewquery = get_view_query(rel);
+					KeyJoinQueryStack qs;
+					KeyJoinFactContext view_context = *context;
+
+					viewquery = copyObject(viewquery);
+					revalidate_stored_key_join_proofs_in_query(viewquery,
+															   NULL);
+					qs.parent = NULL;
+					qs.query = viewquery;
+					view_context.pstate = NULL;
+					view_context.query = viewquery;
+					view_context.query_stack = &qs;
+					view_context.revalidating_stored_query = true;
+					rte->keyJoinFacts =
+						project_key_join_query_facts(&view_context,
+													 viewquery);
+					append_dependency_to_facts(rte->keyJoinFacts,
+											   RelationRelationId,
+											   rte->relid);
+
+					/*
+					 * Facts the view discarded inside its own body carry no
+					 * usable caret, since its text is not the current
+					 * statement.  When the view is referenced from locatable
+					 * top-level text, name it so a rejection can point the
+					 * user at the view to inspect.  Only this outermost
+					 * boundary (enclosing context live) claims origin; inner
+					 * views ran with revalidating_stored_query set.
+					 */
+					if (rte->keyJoinFacts != NULL &&
+						context->pstate != NULL)
+					{
+						Assert(!context->revalidating_stored_query);
+
+						foreach_node(KeyJoinFact, fact,
+									 rte->keyJoinFacts->facts)
+						{
+							if (!fact->active)
+							{
+								Assert(!OidIsValid(fact->inactiveOriginView));
+								fact->inactiveOriginView = rte->relid;
+							}
+						}
+					}
+				}
+				else
+					compute_key_join_relation_facts(context, rte, rel);
+				relation_close(rel, NoLock);
+			}
+			break;
+		case RTE_SUBQUERY:
+			{
+				KeyJoinQueryStack qs;
+
+				Assert(rte->subquery != NULL);
+
+				/*
+				 * LATERAL subqueries need per-outer-row cardinality proof
+				 * before their projected facts can be used soundly, so expose
+				 * no facts from them.
+				 */
+				if (rte->lateral)
+				{
+					rte->keyJoinFacts = NULL;
+					break;
+				}
+
+				/*
+				 * Stored-query replay visits FROM-subqueries before their
+				 * owner's jointree.  This demand path projects facts only.
+				 */
+				if (context->revalidating_stored_query)
+				{
+					Assert(context->pstate == NULL);
+					Assert(context->query_stack != NULL);
+				}
+
+				qs.parent = context->query_stack;
+				qs.query = rte->subquery;
+				{
+					KeyJoinFactContext subcontext = *context;
+
+					subcontext.query = rte->subquery;
+					subcontext.query_stack = &qs;
+					rte->keyJoinFacts =
+						project_key_join_query_facts(&subcontext,
+													 rte->subquery);
+				}
+				break;
+			}
+		case RTE_CTE:
+			{
+				CommonTableExpr *cte = NULL;
+				KeyJoinQueryStack *cte_owner_stack = NULL;
+
+				if (context->query_stack != NULL)
+				{
+					int			levelsup = (int) rte->ctelevelsup;
+
+					/*
+					 * Query-backed CTE resolution starts from this RTE
+					 * reference site.  Walk rte->ctelevelsup to the Query
+					 * that owns the CTE, and keep that owner frame as the CTE
+					 * query's parent stack.
+					 */
+					for (KeyJoinQueryStack *qs = context->query_stack;
+						 qs != NULL;
+						 qs = qs->parent)
+					{
+						if (levelsup == 0)
+						{
+							foreach_node(CommonTableExpr, candidate,
+										 qs->query->cteList)
+							{
+								if (strcmp(candidate->ctename,
+										   rte->ctename) == 0)
+								{
+									cte = candidate;
+									cte_owner_stack = qs;
+									break;
+								}
+							}
+							break;
+						}
+						levelsup--;
+					}
+					Assert(cte == NULL || cte_owner_stack != NULL);
+				}
+
+				/*
+				 * A recursive self-reference names the recursive working
+				 * table, not an ordinary CTE result surface.
+				 */
+				if (rte->self_reference)
+					break;
+
+				if (cte == NULL)
+				{
+					/*
+					 * Stored projection resolves ordinary CTE references
+					 * through the query stack.  If that failed, only live
+					 * demand-driven projection can still resolve the CTE from a
+					 * visible WITH namespace.
+					 */
+					Assert(!context->revalidating_stored_query);
+					Assert(context->pstate != NULL);
+					for (ParseState *ps = context->pstate;
+						 ps != NULL && cte == NULL;
+						 ps = ps->parentParseState)
+					{
+						foreach_node(CommonTableExpr, candidate,
+									 ps->p_ctenamespace)
+						{
+							if (strcmp(candidate->ctename,
+									   rte->ctename) == 0)
+							{
+								cte = candidate;
+								break;
+							}
+						}
+					}
+					cte_owner_stack = NULL;
+				}
+
+				/*
+				 * Only ordinary, resolved, non-recursive CTE queries have a
+				 * single query result surface we can project facts from.  A
+				 * recursive CTE needs fixpoint reasoning this proof model does
+				 * not attempt.
+				 */
+				Assert(cte != NULL);
+				Assert(IsA(cte->ctequery, Query));
+				if (cte->cterecursive)
+					break;
+
+				/*
+				 * Stored-query replay visits CTE queries from their owning
+				 * Query before any RTE_CTE fact projection reaches them.
+				 */
+				if (context->revalidating_stored_query)
+				{
+					Assert(context->pstate == NULL);
+					Assert(cte_owner_stack != NULL);
+				}
+
+				{
+					KeyJoinQueryStack qs;
+
+					qs.parent = cte_owner_stack;
+					qs.query = (Query *) cte->ctequery;
+					{
+						KeyJoinFactContext cte_context = *context;
+
+						cte_context.query = (Query *) cte->ctequery;
+						cte_context.query_stack = &qs;
+						rte->keyJoinFacts =
+							project_key_join_query_facts(&cte_context,
+														 (Query *) cte->ctequery);
+					}
+				}
+				break;
+			}
+		case RTE_JOIN:
+			{
+				Index		rtindex = rtindex_for_rte(context, rte);
+				JoinExpr   *j = find_join_expr_for_rtindex(context, rtindex);
+				Index		left_rtindex;
+				Index		right_rtindex;
+				RangeTblEntry *left_rte;
+				RangeTblEntry *right_rte;
+				bool		use_query = (context->pstate == NULL);
+
+				/*
+				 * Live parse analysis can find the JoinExpr through
+				 * ParseState.  Stored-query revalidation has only the copied
+				 * Query tree, so retry the lookup there.
+				 */
+				if (j == NULL)
+				{
+					KeyJoinFactContext query_context = *context;
+
+					Assert(context->query != NULL);
+					query_context.pstate = NULL;
+					rtindex = rtindex_for_rte(&query_context, rte);
+					j = find_join_expr_for_rtindex(&query_context, rtindex);
+					use_query = true;
+				}
+
+				/* Without the JoinExpr, we cannot find the input surfaces. */
+				Assert(j != NULL);
+
+				/*
+				 * Ordinary joins expose no key-join facts.  Only accepted key
+				 * joins need the input surfaces below.
+				 */
+				if (j->keyJoin == NULL)
+					break;
+
+				left_rtindex = jtnode_surface_rtindex(j->larg);
+				right_rtindex = jtnode_surface_rtindex(j->rarg);
+
+				/*
+				 * A transformed JoinExpr's operands must expose concrete
+				 * surface RTEs.
+				 */
+				Assert(left_rtindex != 0);
+				Assert(right_rtindex != 0);
+				if (use_query)
+				{
+					left_rte = rt_fetch(left_rtindex, context->query->rtable);
+					right_rte = rt_fetch(right_rtindex, context->query->rtable);
+				}
+				else
+				{
+					left_rte = rt_fetch(left_rtindex, context->pstate->p_rtable);
+					right_rte = rt_fetch(right_rtindex, context->pstate->p_rtable);
+				}
+
+				ensure_key_join_surface_facts(context, left_rte);
+				ensure_key_join_surface_facts(context, right_rte);
+
+				compute_join_output_facts(j, left_rtindex, left_rte,
+										  right_rtindex, right_rte,
+										  rte);
+				break;
+			}
+		default:
+			break;
+	}
+
+	rte->keyJoinFactsComputed = true;
+}
+
+/*
+ * compute_key_join_relation_facts
+ *
+ *		Collect catalog-backed surface facts from a base relation RTE.
+ *
+ *		Validated enforced NOT NULL constraints, validated nondeferrable
+ *		FKs, and usable unique indexes become surface facts.
+ */
+static void
+compute_key_join_relation_facts(KeyJoinFactContext *context,
+								RangeTblEntry *rte, Relation rel)
+{
+	KeyJoinSurfaceFacts *set;
+	TupleDesc	tupdesc;
+	List	   *fkeylist;
+	bool		locked_inheritance_shape = false;
+
+	Assert(rte->rtekind == RTE_RELATION);
+
+	/* An ONLY scan of a partitioned parent is not the partition tree. */
+	if (rte->relkind == RELKIND_PARTITIONED_TABLE && !rte->inh)
+		return;
+
+	/* Other relkinds do not provide base table facts here. */
+	if (rte->relkind != RELKIND_RELATION &&
+		rte->relkind != RELKIND_PARTITIONED_TABLE)
+		return;
+
+	if (rel->rd_rel->relrowsecurity)
+		return;
+
+	/*
+	 * An inherited scan of a table with children may include child rows.
+	 * If no children exist, hold ShareLock until transaction end so CREATE
+	 * TABLE ... INHERITS cannot add the first child before proof dependency
+	 * creation catches up.
+	 */
+	if (rte->relkind == RELKIND_RELATION && rte->inh)
+	{
+		LockRelationOid(rte->relid, ShareLock);
+		if (has_subclass(rte->relid))
+		{
+			UnlockRelationOid(rte->relid, ShareLock);
+			return;
+		}
+		locked_inheritance_shape = true;
+	}
+
+	set = makeNode(KeyJoinSurfaceFacts);
+	tupdesc = RelationGetDescr(rel);
+
+	/*
+	 * Fact dependencies are the publication boundary: every object recorded
+	 * below must already be locked for the transaction before the fact is added.
+	 */
+
+	/* Validated NOT NULL constraints feed condition 3. */
+	for (int attno = 1; attno <= tupdesc->natts; attno++)
+	{
+		Form_pg_attribute att = TupleDescAttr(tupdesc, attno - 1);
+		HeapTuple	contup;
+		Form_pg_constraint con;
+		Oid			conoid;
+
+		if (att->attisdropped || !att->attnotnull)
+			continue;
+		contup = findNotNullConstraintAttnum(rte->relid, attno);
+		if (!HeapTupleIsValid(contup))
+			continue;
+		con = (Form_pg_constraint) GETSTRUCT(contup);
+		conoid = con->oid;
+		heap_freetuple(contup);
+
+		contup = lock_and_fetch_key_join_constraint(conoid);
+		con = (Form_pg_constraint) GETSTRUCT(contup);
+		Assert(con->contype == CONSTRAINT_NOTNULL);
+		Assert(con->conrelid == rte->relid);
+		Assert(extractNotNullColumn(contup) == attno);
+		Assert(con->conenforced);
+
+		if (con->convalidated)
+		{
+			KeyJoinFact *fact = add_fact(set, KJF_NOT_NULL);
+
+			fact->attnum = attno;
+			fact->dependencies =
+				list_make1(make_dependency(ConstraintRelationId,
+											con->oid));
+			fact->dependencies =
+				append_dependency_unique(fact->dependencies,
+										 RelationRelationId, rte->relid);
+		}
+		ReleaseSysCache(contup);
+	}
+
+	/* Usable unique indexes feed condition 1 plus base row coverage. */
+	foreach_oid(indexoid, RelationGetIndexList(rel))
+	{
+		Relation	indexrel;
+		Form_pg_index index;
+		AttrNumber	attnums[INDEX_MAX_KEYS];
+		Oid			eqtypes[INDEX_MAX_KEYS];
+		int32		eqtypmods[INDEX_MAX_KEYS];
+		Oid			eqoperators[INDEX_MAX_KEYS];
+		List	   *deps = NIL;
+		Oid			constraint;
+		bool		usable = true;
+		bool		keep_index_lock = false;
+
+		indexrel = index_open(indexoid, ShareLock);
+		index = indexrel->rd_index;
+
+		/*
+		 * Unique proof facts must be unconditional, immediate, valid, plain
+		 * column indexes.  Expressions and predicates would need additional
+		 * implication proof before they could validate arbitrary key joins.
+		 */
+		Assert(index->indnkeyatts > 0);
+		if (!index->indisunique ||
+			!index->indisvalid ||
+			!index->indimmediate ||
+			RelationGetIndexExpressions(indexrel) != NIL ||
+			RelationGetIndexPredicate(indexrel) != NIL)
+		{
+			index_close(indexrel, ShareLock);
+			continue;
+		}
+
+		constraint = get_index_constraint(indexoid);
+		if (OidIsValid(constraint))
+		{
+			HeapTuple	contup;
+
+			contup = lock_and_fetch_key_join_constraint(constraint);
+			ReleaseSysCache(contup);
+		}
+		deps = list_make1(make_dependency(OidIsValid(constraint) ?
+										  ConstraintRelationId :
+										  RelationRelationId,
+										  OidIsValid(constraint) ?
+										  constraint : indexoid));
+		deps = append_dependency_unique(deps, RelationRelationId,
+										rte->relid);
+
+		for (int i = 0; i < index->indnkeyatts; i++)
+		{
+			AttrNumber	attno = index->indkey.values[i];
+			Form_pg_attribute att;
+			Oid			eqtype;
+			int32		eqtypmod;
+			Oid			eqop;
+			Oid			opcintype;
+
+			Assert(attno > 0);
+			att = TupleDescAttr(tupdesc, attno - 1);
+			opcintype = indexrel->rd_opcintype[i];
+
+			/*
+			 * The index key must be the live table column under that column's
+			 * collation, and the collation must make equality deterministic.
+			 */
+			Assert(!att->attisdropped);
+			if (indexrel->rd_indcollation[i] != att->attcollation ||
+				!key_join_collation_is_usable(att->attcollation))
+			{
+				usable = false;
+				break;
+			}
+			eqop = get_opfamily_member_for_cmptype(indexrel->rd_opfamily[i],
+												   opcintype, opcintype,
+												   COMPARE_EQ);
+			if (!OidIsValid(eqop) ||
+				!key_join_equality_operator_is_usable(eqop, opcintype,
+													  &eqtype, &deps))
+			{
+				usable = false;
+				break;
+			}
+			if (!key_join_equality_identity_is_usable(att->atttypid,
+													  att->atttypmod,
+													  eqtype,
+													  &eqtypmod))
+			{
+				usable = false;
+				break;
+			}
+			attnums[i] = attno;
+			eqtypes[i] = eqtype;
+			eqtypmods[i] = eqtypmod;
+			eqoperators[i] = eqop;
+		}
+
+		if (usable)
+		{
+			List	   *keyattnums =
+				list_make_attrnums(attnums, index->indnkeyatts);
+			List	   *keypositions =
+				make_key_positions_from_attrnums(tupdesc, attnums,
+												 index->indnkeyatts,
+												 eqtypes, eqtypmods,
+												 eqoperators);
+			KeyJoinFact *ufact = add_fact(set, KJF_UNIQUE);
+
+			ufact->keyPositions = copyObject(keypositions);
+			ufact->relid = rte->relid;
+			ufact->baseAttnums = list_copy(keyattnums);
+			ufact->dependencies = copyObject(deps);
+
+			add_paired_row_coverage(set, keypositions, rte->relid,
+									keyattnums, copyObject(deps));
+			keep_index_lock = true;
+		}
+
+		index_close(indexrel, keep_index_lock ? NoLock : ShareLock);
+	}
+
+	/*
+	 * Validated, catalog-enforced, nondeferrable equality FKs feed condition
+	 * 2 plus base row coverage; period FKs are skipped.
+	 *
+	 * This intentionally trusts pg_constraint's catalog contract.  We do not
+	 * inspect current or historical RI trigger enablement here; orphan rows
+	 * created through privileged trigger bypass are referential-integrity
+	 * corruption outside the key-join proof model, not proof facts to audit
+	 * during parse analysis.
+	 */
+	fkeylist = copyObject(RelationGetFKeyList(rel));
+
+	INJECTION_POINT("key-join-after-fkey-list-copy", NULL);
+
+	foreach_node(ForeignKeyCacheInfo, fk, fkeylist)
+	{
+		HeapTuple	contup;
+		Form_pg_constraint con;
+		KeyJoinFact *fact;
+		Relation	refrel;
+		TupleDesc	reftupdesc;
+		List	   *deps;
+		int			nkeys;
+		AttrNumber	conkey[INDEX_MAX_KEYS];
+		AttrNumber	confkey[INDEX_MAX_KEYS];
+		Oid			pf_eq_oprs[INDEX_MAX_KEYS];
+		Oid			pp_eq_oprs[INDEX_MAX_KEYS];
+		Oid			ff_eq_oprs[INDEX_MAX_KEYS];
+		Oid			eqtypes[INDEX_MAX_KEYS];
+		int32		eqtypmods[INDEX_MAX_KEYS];
+		Oid			eqoperators[INDEX_MAX_KEYS];
+		bool		usable = true;
+
+		contup = lock_and_fetch_key_join_constraint(fk->conoid);
+		con = (Form_pg_constraint) GETSTRUCT(contup);
+		Assert(con->contype == CONSTRAINT_FOREIGN);
+		Assert(con->conrelid == rte->relid);
+		if (!con->conenforced ||
+			!con->convalidated ||
+			con->condeferrable ||
+			con->conperiod)
+		{
+			ReleaseSysCache(contup);
+			continue;
+		}
+		deps = list_make1(make_dependency(ConstraintRelationId, con->oid));
+		deps = append_dependency_unique(deps, RelationRelationId,
+										rte->relid);
+
+		/*
+		 * FKs referencing partitioned tables have child pg_constraint rows
+		 * for each referenced partition.  Those rows are enforcement
+		 * machinery for the parent FK; they do not prove that every
+		 * referencing value is contained in that one partition's keyspace. In
+		 * contrast, a referencing-side partition can inherit an FK whose
+		 * referenced relation is the same as the root FK's referenced
+		 * relation, and that remains a valid containment proof for the leaf
+		 * relation.
+		 */
+		if (OidIsValid(con->conparentid))
+		{
+			Oid			referencedRelid = con->confrelid;
+			Oid			parentid = con->conparentid;
+
+			while (OidIsValid(parentid))
+			{
+				HeapTuple	parenttup;
+				Form_pg_constraint parentcon;
+				Oid			nextparentid;
+
+				parenttup = lock_and_fetch_key_join_constraint(parentid);
+				parentcon = (Form_pg_constraint) GETSTRUCT(parenttup);
+				Assert(parentcon->contype == CONSTRAINT_FOREIGN);
+				Assert(!dependency_member(deps, ConstraintRelationId,
+										  parentid));
+				deps = lappend(deps,
+							   make_dependency(ConstraintRelationId,
+											   parentid));
+
+				nextparentid = parentcon->conparentid;
+				if (!OidIsValid(nextparentid) &&
+					parentcon->confrelid != referencedRelid)
+					usable = false;
+
+				ReleaseSysCache(parenttup);
+
+				if (!usable)
+					break;
+				if (!OidIsValid(nextparentid))
+					break;
+				parentid = nextparentid;
+			}
+
+			if (!usable)
+			{
+				ReleaseSysCache(contup);
+				continue;
+			}
+		}
+
+		DeconstructFkConstraintRow(contup, &nkeys, conkey, confkey,
+								   pf_eq_oprs, pp_eq_oprs, ff_eq_oprs,
+								   NULL, NULL);
+
+		/*
+		 * The per-column checks below are proof-eligibility checks, not
+		 * catalog invariants.  PostgreSQL can enforce valid FKs whose types,
+		 * typmods, collations, or RI equality operators are not identical
+		 * enough for key-join proof facts.  Such FKs remain valid; they just
+		 * do not contribute facts here.
+		 */
+		refrel = relation_open(con->confrelid, AccessShareLock);
+		reftupdesc = RelationGetDescr(refrel);
+		for (int i = 0; i < nkeys; i++)
+		{
+			Form_pg_attribute fkatt;
+			Form_pg_attribute pkatt;
+			Oid			eqtype;
+			int32		eqtypmod;
+
+			Assert(conkey[i] > 0);
+			Assert(confkey[i] > 0);
+			fkatt = TupleDescAttr(tupdesc, conkey[i] - 1);
+			pkatt = TupleDescAttr(reftupdesc, confkey[i] - 1);
+
+			/*
+			 * A live foreign-key constraint cannot name dropped columns:
+			 * dependency processing would have removed the constraint, or
+			 * rejected the drop, before either column could be marked dropped.
+			 */
+			Assert(!fkatt->attisdropped);
+			Assert(!pkatt->attisdropped);
+			if (fkatt->atttypid != pkatt->atttypid ||
+				fkatt->atttypmod != pkatt->atttypmod ||
+				fkatt->attcollation != pkatt->attcollation ||
+				!key_join_collation_is_usable(fkatt->attcollation) ||
+				pf_eq_oprs[i] != pp_eq_oprs[i] ||
+				!key_join_equality_operator_is_usable(pf_eq_oprs[i],
+													  InvalidOid,
+													  &eqtype, &deps) ||
+				!key_join_equality_identity_is_usable(fkatt->atttypid,
+													  fkatt->atttypmod,
+													  eqtype,
+													  &eqtypmod))
+			{
+				usable = false;
+				break;
+			}
+			Assert(pf_eq_oprs[i] == ff_eq_oprs[i]);
+			eqtypes[i] = eqtype;
+			eqtypmods[i] = eqtypmod;
+			eqoperators[i] = pf_eq_oprs[i];
+		}
+		relation_close(refrel, AccessShareLock);
+		if (!usable)
+		{
+			ReleaseSysCache(contup);
+			continue;
+		}
+
+		fact = add_fact(set, KJF_FOREIGN_KEY);
+		fact->keyPositions =
+			make_key_positions_from_attrnums(tupdesc, conkey, nkeys,
+											 eqtypes, eqtypmods,
+											 eqoperators);
+		fact->relid = con->conrelid;
+		fact->baseAttnums = list_make_attrnums(conkey, nkeys);
+		fact->referencedRelid = con->confrelid;
+		fact->referencedAttnums = list_make_attrnums(confkey, nkeys);
+		fact->constraint = con->oid;
+		fact->dependencies = copyObject(deps);
+
+		add_paired_row_coverage(set, copyObject(fact->keyPositions),
+								fact->relid, list_copy(fact->baseAttnums),
+								deps);
+
+		ReleaseSysCache(contup);
+	}
+	list_free_deep(fkeylist);
+
+	if (set->facts != NIL)
+		rte->keyJoinFacts = set;
+	else if (locked_inheritance_shape)
+		UnlockRelationOid(rte->relid, ShareLock);
+}
+
+/*
+ * rtindex_for_rte
+ *
+ *		Find the range-table index for an RTE pointer in the live ParseState
+ *		or saved Query that owns it.
+ */
+static Index
+rtindex_for_rte(KeyJoinFactContext *context, RangeTblEntry *rte)
+{
+	List	   *rtable;
+	int			rtindex = 1;
+
+	Assert(context != NULL);
+	Assert(rte != NULL);
+
+	if (context->pstate != NULL)
+		rtable = context->pstate->p_rtable;
+	else
+	{
+		Assert(context->query != NULL);
+		rtable = context->query->rtable;
+	}
+
+	foreach_node(RangeTblEntry, candidate, rtable)
+	{
+		if (candidate == rte)
+			return rtindex;
+		rtindex++;
+	}
+
+	Assert(context->pstate != NULL);
+	return 0;
+}
+
+/*
+ * find_join_expr_for_rtindex
+ *
+ *		Find the JoinExpr attached to a JOIN RTE.
+ */
+static JoinExpr *
+find_join_expr_for_rtindex(KeyJoinFactContext *context, Index rtindex)
+{
+	Assert(context != NULL);
+
+	if (rtindex == 0)
+		return NULL;
+
+	if (context->pstate != NULL)
+	{
+		Node	   *node;
+
+		Assert(rtindex <= list_length(context->pstate->p_joinexprs));
+		node = list_nth(context->pstate->p_joinexprs, rtindex - 1);
+		Assert(node != NULL);
+		return castNode(JoinExpr, node);
+	}
+
+	Assert(context->query != NULL);
+	return find_join_expr_in_jointree((Node *) context->query->jointree,
+									  rtindex);
+}
+
+/*
+ * find_join_expr_in_jointree
+ *
+ *		Search a saved Query jointree for the JoinExpr with rtindex.
+ */
+static JoinExpr *
+find_join_expr_in_jointree(Node *jtnode, Index rtindex)
+{
+	Assert(jtnode != NULL);
+
+	if (IsA(jtnode, JoinExpr))
+	{
+		JoinExpr   *j = castNode(JoinExpr, jtnode);
+		JoinExpr   *result;
+
+		if (j->rtindex == rtindex)
+			return j;
+		result = find_join_expr_in_jointree(j->larg, rtindex);
+		return result ? result : find_join_expr_in_jointree(j->rarg, rtindex);
+	}
+	if (IsA(jtnode, FromExpr))
+	{
+		JoinExpr   *result = NULL;
+
+		foreach_ptr(Node, child, castNode(FromExpr, jtnode)->fromlist)
+		{
+			result = find_join_expr_in_jointree(child, rtindex);
+
+			if (result != NULL)
+				break;
+		}
+		return result;
+	}
+
+	return NULL;
+}
+
+/*
+ * project_key_join_query_facts
+ *
+ *		Compute surface facts exposed by a query targetlist.
+ *
+ *		Query shape determines which base facts survive projection and
+ *		whether GROUP BY or DISTINCT introduces query-level uniqueness.
+ */
+static KeyJoinSurfaceFacts *
+project_key_join_query_facts(KeyJoinFactContext *context, Query *query)
+{
+	KeyJoinSurfaceFacts *result;
+	int			natts;
+	int		   *srcvarno;
+	int		   *srcattno;
+	int			outattno = 0;
+	Node	   *topjtnode = NULL;
+	Index		top_rtindex = 0;
+	RangeTblEntry *toprte = NULL;
+	bool		block_rowcoverage;
+	List	   *rowcoverage_key_position_sets = NIL;
+
+	Assert(context != NULL);
+	Assert(query != NULL);
+	Assert(context->query == NULL || context->query == query);
+	Assert(!context->revalidating_stored_query ||
+		   context->query_stack != NULL);
+
+	/*
+	 * Non-SELECT query trees, set operations, SRFs, and grouping sets do not
+	 * expose a simple targetlist projection surface for facts to pass through.
+	 */
+	if (query->commandType != CMD_SELECT ||
+		query->setOperations != NULL ||
+		query->hasTargetSRFs ||
+		query->groupingSets != NIL)
+		return NULL;
+
+	/*
+	 * Volatile expressions can change state that matched-filter proofs read
+	 * while the executor evaluates later operands.  Treat them as a complete
+	 * proof barrier for computed query facts.
+	 */
+	if (key_join_contains_volatile_after_planning((Node *) query))
+		return NULL;
+
+	natts = list_length(query->targetList);
+	srcvarno = palloc0_array(int, natts + 1);
+	srcattno = palloc0_array(int, natts + 1);
+
+	foreach_node(TargetEntry, tle, query->targetList)
+	{
+		Var		   *var;
+
+		if (tle->resjunk)
+			continue;
+		outattno++;
+		var = direct_var_from_node((Node *) tle->expr);
+		if (var)
+		{
+			RangeTblEntry *varrte = rt_fetch(var->varno, query->rtable);
+
+			if (varrte->rtekind == RTE_GROUP)
+			{
+				Assert(var->varattno > 0);
+				Assert(var->varattno <= list_length(varrte->groupexprs));
+				var = direct_var_from_node((Node *)
+										   list_nth(varrte->groupexprs,
+													var->varattno - 1));
+			}
+		}
+		if (var)
+		{
+			srcvarno[outattno] = var->varno;
+			srcattno[outattno] = var->varattno;
+		}
+	}
+
+	result = makeNode(KeyJoinSurfaceFacts);
+
+	/*
+	 * HAVING/LIMIT/OFFSET/FOR UPDATE can remove rows post-base, so they block
+	 * row coverage but not other facts.
+	 */
+	block_rowcoverage = query->havingQual != NULL ||
+		query->limitOffset != NULL ||
+		query->limitCount != NULL ||
+		query->rowMarks != NIL;
+
+	/*
+	 * GROUP BY and DISTINCT can remove rows.  A row-coverage key survives
+	 * only when every row-collapsing stage groups/distincts by that key under
+	 * exactly the same equality identity used by the key proof.  If both
+	 * stages are present, either one could otherwise discard a needed key.
+	 */
+	if (query->groupClause != NIL)
+	{
+		KeyJoinRowCollapse *rc = palloc(sizeof(KeyJoinRowCollapse));
+
+		rc->reason = KJI_GROUP_BY;
+		rc->positions =
+			make_rowcollapse_key_positions(query, query->groupClause);
+		rowcoverage_key_position_sets =
+			lappend(rowcoverage_key_position_sets, rc);
+	}
+	if (query->distinctClause != NIL)
+	{
+		KeyJoinRowCollapse *rc = palloc(sizeof(KeyJoinRowCollapse));
+
+		rc->reason = KJI_DISTINCT;
+		rc->positions =
+			make_rowcollapse_key_positions(query, query->distinctClause);
+		rowcoverage_key_position_sets =
+			lappend(rowcoverage_key_position_sets, rc);
+	}
+
+	Assert(query->jointree != NULL);
+	if (list_length(query->jointree->fromlist) == 1)
+	{
+		topjtnode = linitial(query->jointree->fromlist);
+		top_rtindex = jtnode_surface_rtindex(topjtnode);
+		Assert(top_rtindex > 0);
+		toprte = rt_fetch(top_rtindex, query->rtable);
+		ensure_key_join_surface_facts(context, toprte);
+	}
+
+	if (toprte != NULL && toprte->keyJoinFacts != NULL)
+	{
+		int			ncols = list_length(toprte->eref->colnames);
+		List	  **attrmap = palloc0_array(List *, ncols + 1);
+
+		for (int i = 1; i <= outattno; i++)
+		{
+			List	   *mapped_attnums;
+
+			if (srcattno[i] <= 0)
+				continue;
+			mapped_attnums = map_var_to_jtnode_surface(query, topjtnode,
+													   srcvarno[i], srcattno[i]);
+			foreach_int(topattno, mapped_attnums)
+			{
+				Assert(topattno > 0);
+				Assert(topattno <= ncols);
+				attrmap[topattno] = lappend_int(attrmap[topattno], i);
+			}
+		}
+
+		project_key_join_facts_from_rte(result, toprte, attrmap,
+										true, true, !block_rowcoverage,
+										query->jointree->quals,
+										query, topjtnode, top_rtindex,
+										rowcoverage_key_position_sets, NIL,
+										KJI_ROW_REMOVING_CLAUSE);
+		pfree(attrmap);
+	}
+
+	/*
+	 * GROUP BY / DISTINCT also proves uniqueness of the grouped/distinct
+	 * output columns.  These query-level facts have no base-relation relid,
+	 * but still depend on the equality operators and functions used below.
+	 */
+	if (query->groupClause != NIL || query->distinctClause != NIL)
+	{
+		List	   *clauses = query->groupClause != NIL ?
+			query->groupClause : query->distinctClause;
+		List	   *attnums = NIL;
+		List	   *keypositions = NIL;
+		List	   *deps = NIL;
+		bool		usable = true;
+
+		foreach_node(SortGroupClause, sgc, clauses)
+		{
+			TargetEntry *tle = get_sortgroupref_tle(sgc->tleSortGroupRef,
+													query->targetList);
+
+			Assert(tle != NULL);
+			Assert(OidIsValid(sgc->eqop));
+			if (tle->resjunk)
+			{
+				usable = false;
+				break;
+			}
+			if (direct_var_from_node((Node *) tle->expr) == NULL)
+			{
+				usable = false;
+				break;
+			}
+			if (!list_member_int(attnums, tle->resno))
+			{
+				Node	   *expr = (Node *) tle->expr;
+				Oid			eqtype;
+				int32		eqtypmod;
+
+				if (!key_join_collation_is_usable(exprCollation(expr)))
+				{
+					usable = false;
+					break;
+				}
+				if (!key_join_equality_operator_is_usable(sgc->eqop,
+														  InvalidOid,
+														  &eqtype, &deps))
+				{
+					usable = false;
+					break;
+				}
+				if (!key_join_equality_identity_is_usable(exprType(expr),
+														  exprTypmod(expr),
+														  eqtype,
+														  &eqtypmod))
+				{
+					usable = false;
+					break;
+				}
+				attnums = lappend_int(attnums, tle->resno);
+				keypositions =
+					lappend(keypositions,
+							make_key_position(list_make1_int(tle->resno),
+											  exprType(expr),
+											  exprTypmod(expr),
+											  exprCollation(expr),
+											  eqtype, eqtypmod,
+											  sgc->eqop));
+			}
+		}
+
+		if (usable)
+		{
+			KeyJoinFact *fact = add_fact(result, KJF_UNIQUE);
+
+			Assert(attnums != NIL);
+			fact->keyPositions = keypositions;
+			fact->relid = InvalidOid;
+			fact->baseAttnums = list_copy(attnums);
+			fact->dependencies = deps;
+		}
+	}
+
+	pfree(srcvarno);
+	pfree(srcattno);
+
+	if (result->facts == NIL)
+		return NULL;
+	return result;
+}
+
+/*
+ * project_key_join_facts_from_rte
+ *
+ *		Project surface facts from one RTE into another surface fact set.
+ *
+ *		The preservation flags describe which proof meanings the caller preserved.
+ *		Foreign-key containment projects whenever its key columns survive;
+ *		row-coverage projection rejects lossy filter handling because filters
+ *		define the referenced multiset.
+ */
+static void
+project_key_join_facts_from_rte(KeyJoinSurfaceFacts *dst, RangeTblEntry *src,
+								List **attrmap, bool preserve_notnull,
+								bool preserve_unique, bool preserve_rowcoverage,
+								Node *filter_qual, Query *filter_query,
+								Node *filter_jtnode, Index filter_rtindex,
+								List *rowcoverage_key_position_sets,
+								List *extra_unique_deps,
+								KeyJoinInactiveReason inact_reason)
+{
+	bool		tablesample;
+	int			natts;
+
+	Assert(src != NULL);
+	Assert(src->keyJoinFacts != NULL);
+
+	tablesample = (src->rtekind == RTE_RELATION && src->tablesample != NULL);
+	Assert(attrmap != NULL);
+	natts = list_length(src->eref->colnames);
+
+	foreach_node(KeyJoinFact, old, src->keyJoinFacts->facts)
+	{
+		KeyJoinFact *new;
+		List	   *newpositions;
+
+		/* Carry an already-inactive fact forward inert (keeps its stamp). */
+		if (!old->active)
+		{
+			add_inactive_projected(dst, old, attrmap, KJI_NONE);
+			continue;
+		}
+
+		switch (old->kind)
+		{
+			case KJF_NOT_NULL:
+				if (!preserve_notnull)
+				{
+					add_inactive_projected(dst, old, attrmap,
+										   KJI_NULL_EXTENDING_JOIN);
+					continue;
+				}
+				Assert(old->attnum > 0 && old->attnum <= natts);
+				if (attrmap[old->attnum] == NIL)
+					continue;
+				foreach_int(attno, attrmap[old->attnum])
+				{
+					new = copyObject(old);
+					new->attnum = attno;
+					dst->facts = lappend(dst->facts, new);
+				}
+				continue;
+
+			case KJF_UNIQUE:
+				if (!preserve_unique)
+				{
+					add_inactive_projected(dst, old, attrmap, KJI_JOIN_FANOUT);
+					continue;
+				}
+				Assert(list_length(old->baseAttnums) ==
+					   list_length(old->keyPositions));
+				newpositions = project_key_positions(old->keyPositions, attrmap);
+				if (newpositions == NIL)
+					continue;
+				new = copyObject(old);
+				new->keyPositions = newpositions;
+				new->dependencies =
+					append_dependencies_unique(new->dependencies,
+											   extra_unique_deps);
+				dst->facts = lappend(dst->facts, new);
+				continue;
+
+			case KJF_ROW_COVERAGE:
+				{
+					bool		rowcollapse_sets_cover = true;
+					KeyJoinInactiveReason rowcollapse_reason = KJI_NONE;
+
+					if (!preserve_rowcoverage)
+					{
+						add_inactive_projected(dst, old, attrmap, inact_reason);
+						continue;
+					}
+					if (tablesample)
+					{
+						add_inactive_projected(dst, old, attrmap,
+											   KJI_ROW_REMOVING_CLAUSE);
+						continue;
+					}
+					Assert(list_length(old->baseAttnums) ==
+						   list_length(old->keyPositions));
+					newpositions = project_key_positions(old->keyPositions,
+														 attrmap);
+					if (newpositions == NIL)
+						continue;
+
+					/*
+					 * Every row-collapsing stage must group/distinct on this
+					 * row-coverage key under the same equality identity.
+					 * Null-containing referenced rows are outside the
+					 * referenced multiset.
+					 */
+					foreach_ptr(KeyJoinRowCollapse, rc,
+								rowcoverage_key_position_sets)
+					{
+						if (!rowcollapse_preserves_rowcoverage(newpositions,
+															   rc->positions))
+						{
+							rowcollapse_sets_cover = false;
+							rowcollapse_reason = rc->reason;
+							break;
+						}
+					}
+					if (!rowcollapse_sets_cover)
+					{
+						add_inactive_projected(dst, old, attrmap,
+											   rowcollapse_reason);
+						continue;
+					}
+
+					new = copyObject(old);
+					new->keyPositions = newpositions;
+					/*
+					 * Row coverage must account for every filter.  Dropping
+					 * one would claim coverage for rows no longer visible.
+					 */
+					if (!add_filter_conjuncts(&new->filterConjuncts,
+											  new->keyPositions, filter_qual,
+											  filter_query, filter_jtnode,
+											  filter_rtindex, attrmap, natts,
+											  &new->dependencies, true))
+					{
+						add_inactive_projected(dst, old, attrmap,
+											   KJI_UNACCOUNTED_FILTER);
+						continue;
+					}
+					dst->facts = lappend(dst->facts, new);
+					continue;
+				}
+
+			default:
+				Assert(old->kind == KJF_FOREIGN_KEY);
+				Assert(list_length(old->baseAttnums) ==
+					   list_length(old->keyPositions));
+				newpositions = project_key_positions(old->keyPositions, attrmap);
+				if (newpositions == NIL)
+					continue;
+				new = copyObject(old);
+				new->keyPositions = newpositions;
+				/*
+				 * FK-side filters are optional precision; unrecognized
+				 * conjuncts only remove referencing rows.
+				 */
+				(void) add_filter_conjuncts(&new->filterConjuncts,
+											new->keyPositions, filter_qual,
+											filter_query, filter_jtnode,
+											filter_rtindex, attrmap, natts,
+											&new->dependencies, false);
+				dst->facts = lappend(dst->facts, new);
+				continue;
+		}
+	}
+}
+
+/*
+ * project_key_positions
+ *
+ *		Project key positions through an attrmap.
+ *
+ *		Returns NIL if any key position is lost by the projection.
+ */
+static List *
+project_key_positions(List *keyPositions, List **attrmap)
+{
+	List	   *result = NIL;
+
+	foreach_node(KeyJoinKeyPosition, oldpos, keyPositions)
+	{
+		List	   *newattnums = NIL;
+
+		foreach_int(attno, oldpos->attnums)
+		{
+			Assert(attno > 0);
+			newattnums = list_concat(newattnums, list_copy(attrmap[attno]));
+		}
+		if (newattnums == NIL)
+			return NIL;
+		result = lappend(result,
+						 make_key_position(newattnums, oldpos->typeOid,
+										   oldpos->typmod, oldpos->collationOid,
+										   oldpos->eqTypeOid,
+										   oldpos->eqTypmod,
+										   oldpos->eqOperator));
+	}
+	return result;
+}
+
+/*
+ * make_rowcollapse_key_positions
+ *
+ *		Build key-position evidence for one row-collapsing stage.
+ */
+static List *
+make_rowcollapse_key_positions(Query *query, List *clauses)
+{
+	List	   *result = NIL;
+	List	   *attnums = NIL;
+
+	foreach_node(SortGroupClause, sgc, clauses)
+	{
+		TargetEntry *tle = get_sortgroupref_tle(sgc->tleSortGroupRef,
+												query->targetList);
+		Node	   *expr;
+
+		Assert(tle != NULL);
+		Assert(OidIsValid(sgc->eqop));
+		if (tle->resjunk)
+			continue;
+		if (direct_var_from_node((Node *) tle->expr) == NULL)
+			continue;
+		if (list_member_int(attnums, tle->resno))
+			continue;
+
+		expr = (Node *) tle->expr;
+		{
+			List	   *deps = NIL;
+			Oid			eqtype;
+			int32		eqtypmod;
+
+			if (!key_join_equality_operator_is_usable(sgc->eqop,
+													  InvalidOid,
+													  &eqtype, &deps))
+				continue;
+			if (!key_join_equality_identity_is_usable(exprType(expr),
+													  exprTypmod(expr),
+													  eqtype,
+													  &eqtypmod))
+				continue;
+
+			attnums = lappend_int(attnums, tle->resno);
+			result =
+				lappend(result,
+						make_key_position(list_make1_int(tle->resno),
+										  exprType(expr),
+										  exprTypmod(expr),
+										  exprCollation(expr),
+										  eqtype, eqtypmod,
+										  sgc->eqop));
+		}
+	}
+	return result;
+}
+
+/*
+ * add_filter_conjuncts
+ *
+ *		Canonicalize filter conjuncts for a projected proof fact.
+ *
+ *		Only direct key = value filters matching the key's equality-input
+ *		identity are retained.  If reject_lossy_filter is true, failure to
+ *		retain any conjunct makes the whole projection fail; otherwise such
+ *		conjuncts are ignored.
+ */
+static bool
+add_filter_conjuncts(List **dst, List *keyPositions,
+					 Node *qual, Query *filter_query, Node *filter_jtnode,
+					 Index filter_rtindex, List **filter_attrmap,
+					 int filter_natts, List **dependencies,
+					 bool reject_lossy_filter)
+{
+	if (qual == NULL)
+		return true;
+	Assert(filter_attrmap != NULL);
+
+	foreach_ptr(Node, conjunct, make_ands_implicit((Expr *) qual))
+	{
+		Node	   *canon = NULL;
+
+		Assert(conjunct != NULL);
+		if (IsA(conjunct, OpExpr))
+		{
+			OpExpr	   *op = castNode(OpExpr, conjunct);
+
+			if (list_length(op->args) == 2)
+			{
+				Node	   *left = linitial(op->args);
+				Node	   *right = lsecond(op->args);
+				Var		   *leftvar = direct_filter_var_from_node(left);
+				int			pos = -1;
+
+				if (leftvar != NULL &&
+					!contain_vars_of_level(right, 0))
+				{
+					List	   *surface_attnums = NIL;
+
+					/*
+					 * Map the filter Var through the optional query,
+					 * jointree, and attrmap context before matching a
+					 * projected key position.
+					 */
+					Assert(leftvar->varlevelsup == 0);
+					Assert(leftvar->varattno > 0);
+					if (filter_query != NULL)
+					{
+						Assert(filter_jtnode != NULL);
+
+						/*
+						 * A filter attached above a join tree names an RTE
+						 * below that tree.  Map it to the visible surface
+						 * column(s) of the filtered jointree.
+						 */
+						surface_attnums =
+							map_var_to_jtnode_surface(filter_query,
+													  filter_jtnode,
+													  leftvar->varno,
+													  leftvar->varattno);
+					}
+					else
+					{
+						Assert(filter_rtindex != 0);
+						if (leftvar->varno == filter_rtindex)
+						{
+							/*
+							 * A base-relation filter already names its
+							 * surface directly.
+							 */
+							surface_attnums =
+								list_make1_int(leftvar->varattno);
+						}
+					}
+
+					foreach_int(srcattno, surface_attnums)
+					{
+						List	   *dst_attnums;
+
+						Assert(srcattno > 0);
+						Assert(srcattno <= filter_natts);
+
+						/*
+						 * Projection may duplicate or drop source columns.
+						 * Follow every surviving output attnum.
+						 */
+						dst_attnums = filter_attrmap[srcattno];
+
+						foreach_int(dstattno, dst_attnums)
+						{
+							int			keypos =
+								key_position_index_for_attnum(keyPositions,
+															  dstattno);
+
+							if (keypos < 0)
+								continue;
+							Assert(pos < 0 || pos == keypos);
+							pos = keypos;
+						}
+					}
+				}
+
+				if (pos >= 0)
+				{
+					List	   *value_deps = NIL;
+
+					/*
+					 * The RHS must be inspected only after locking objects that
+					 * could change filter proof semantics, such as functions.
+					 */
+					(void) collect_filter_expr_dependencies_walker(right,
+																   &value_deps);
+					lock_key_join_dependencies_or_error(value_deps);
+
+					if (filter_value_allowed(right))
+					{
+						KeyJoinKeyPosition *keypos =
+							list_nth_node(KeyJoinKeyPosition, keyPositions,
+										  pos);
+
+						Assert(leftvar->vartype == keypos->typeOid);
+						Assert(leftvar->vartypmod == keypos->typmod);
+						Assert(leftvar->varcollid == keypos->collationOid);
+						Assert(exprType(left) == keypos->eqTypeOid);
+						Assert(exprTypmod(left) == keypos->eqTypmod);
+						if (filter_operator_matches_key_position(op, keypos,
+																 true))
+							canon = make_canonical_filter_conjunct(keypos,
+																   pos,
+																   op, right);
+					}
+				}
+			}
+		}
+
+		if (canon != NULL)
+		{
+			List	   *lockdeps = NIL;
+
+			/*
+			 * Lock filter operators and functions before publishing them as
+			 * fact dependencies.
+			 */
+			(void) collect_filter_expr_dependencies_walker(canon,
+														   &lockdeps);
+			lock_key_join_dependencies_or_error(lockdeps);
+			Assert(!contain_subplans(canon));
+			if (key_join_contains_volatile_after_planning(canon))
+			{
+				if (reject_lossy_filter)
+					return false;
+				continue;
+			}
+			Assert(dependencies != NULL);
+			(void) collect_filter_expr_dependencies_walker(canon,
+														   dependencies);
+			if (!list_contains_equal_node(*dst, canon))
+				*dst = lappend(*dst, canon);
+			continue;
+		}
+		if (reject_lossy_filter)
+			return false;
+	}
+	return true;
+}
+
+/*
+ * jtnode_surface_rtindex
+ *
+ *		Return the RTE index for a jointree node surface.
+ */
+static Index
+jtnode_surface_rtindex(Node *jtnode)
+{
+	Assert(jtnode != NULL);
+	Assert(IsA(jtnode, RangeTblRef) || IsA(jtnode, JoinExpr));
+
+	if (IsA(jtnode, RangeTblRef))
+		return castNode(RangeTblRef, jtnode)->rtindex;
+	else
+		return castNode(JoinExpr, jtnode)->rtindex;
+}
+
+/*
+ * map_var_to_jtnode_surface
+ *
+ *		Map a Var reference to column numbers on a jointree surface.
+ */
+static List *
+map_var_to_jtnode_surface(Query *query, Node *jtnode,
+						  Index varno, AttrNumber attno)
+{
+	Assert(jtnode != NULL);
+	Assert(attno > 0);
+
+	if (IsA(jtnode, RangeTblRef))
+	{
+		Index		rtindex = castNode(RangeTblRef, jtnode)->rtindex;
+
+		return (rtindex == varno) ? list_make1_int(attno) : NIL;
+	}
+
+	{
+		JoinExpr   *j = castNode(JoinExpr, jtnode);
+		RangeTblEntry *joinrte = rt_fetch(j->rtindex, query->rtable);
+		List	   *result = NIL;
+
+		Assert(j->rtindex != varno);
+
+		result = list_concat(result,
+							 append_join_input_mapping(joinrte, true,
+													   map_var_to_jtnode_surface(query, j->larg,
+																				 varno, attno)));
+		result = list_concat(result,
+							 append_join_input_mapping(joinrte, false,
+													   map_var_to_jtnode_surface(query, j->rarg,
+																				 varno, attno)));
+		return result;
+	}
+}
+
+/*
+ * append_join_input_mapping
+ *
+ *		Map input attnums from one join side to JOIN output attnums.
+ */
+static List *
+append_join_input_mapping(RangeTblEntry *joinrte, bool leftside,
+						  List *input_attnums)
+{
+	List	   *result = NIL;
+	List	   *joincols = leftside ? joinrte->joinleftcols :
+		joinrte->joinrightcols;
+
+	foreach_int(input_attno, input_attnums)
+	{
+		foreach_int(joinattno, joincols)
+		{
+			int			input_colno = foreach_current_index(joinattno) + 1;
+
+			if (joinattno == input_attno)
+				result = list_append_unique_int(result,
+												join_output_attno_for_input(joinrte,
+																			leftside,
+																			input_colno));
+		}
+	}
+	return result;
+}
+
+/*
+ * join_output_attno_for_input
+ *
+ *		Return the JOIN output attnum for one input column number.
+ */
+static int
+join_output_attno_for_input(RangeTblEntry *joinrte, bool leftside, int input_colno)
+{
+	if (leftside)
+		return input_colno;
+
+	Assert(joinrte->joinmergedcols == 0);
+	return list_length(joinrte->joinleftcols) +
+		input_colno;
+}
+
+/*
+ * direct_var_from_node_allow_outer
+ *
+ *		Return a direct Var, rejecting parser coercion wrappers.
+ *
+ *		Unlike direct_var_from_node, this allows outer Vars.
+ */
+static Var *
+direct_var_from_node_allow_outer(Node *node)
+{
+	Assert(node != NULL);
+
+	if (IsA(node, Var))
+	{
+		Var		   *var = castNode(Var, node);
+
+		return (var->varattno > 0) ? var : NULL;
+	}
+	if (IsA(node, RelabelType))
+	{
+		RelabelType *relabel = castNode(RelabelType, node);
+		Node	   *arg PG_USED_FOR_ASSERTS_ONLY = (Node *) relabel->arg;
+
+		Assert(arg != NULL);
+		/*
+		 * The parser constructors that can reach key-join proof either return
+		 * the original node for no-op coercions or generate RelabelTypes that
+		 * change type, typmod, or collation.  An identity RelabelType here
+		 * would be a parse-tree invariant violation, not a testable SQL
+		 * shape.
+		 */
+		Assert(relabel->resulttype != exprType(arg) ||
+			   relabel->resulttypmod != exprTypmod(arg) ||
+			   relabel->resultcollid != exprCollation(arg));
+		return NULL;
+	}
+	return NULL;
+}
+
+/*
+ * direct_var_from_node
+ *
+ *		Return a direct current-query Var.
+ */
+static Var *
+direct_var_from_node(Node *node)
+{
+	Var		   *var = direct_var_from_node_allow_outer(node);
+
+	if (var == NULL)
+		return NULL;
+	if (var->varlevelsup != 0)
+		return NULL;
+	return var;
+}
+
+/*
+ * direct_filter_var_from_node
+ *
+ *		Return a direct current-query Var from a key-filter operand.  Domain
+ *		equality operators are resolved on the base type, so the parser may
+ *		wrap a domain Var in a RelabelType before filter canonicalization sees
+ *		the OpExpr.  Other RelabelType shapes are not direct key filters.
+ */
+static Var *
+direct_filter_var_from_node(Node *node)
+{
+	Var		   *var = direct_var_from_node(node);
+
+	if (var != NULL)
+		return var;
+	Assert(node != NULL);
+	if (!IsA(node, RelabelType))
+		return NULL;
+
+	{
+		RelabelType *relabel = castNode(RelabelType, node);
+		int32		baseTypmod;
+		Oid			baseType;
+
+		var = direct_var_from_node((Node *) relabel->arg);
+		if (var == NULL)
+			return NULL;
+
+		baseTypmod = var->vartypmod;
+		baseType = key_join_equality_type(var->vartype, var->vartypmod,
+										  &baseTypmod);
+		Assert(relabel->resultcollid == var->varcollid);
+		if (relabel->resulttype == baseType &&
+			relabel->resulttypmod == baseTypmod)
+			return var;
+		if (baseType == VARCHAROID &&
+			relabel->resulttype == TEXTOID)
+		{
+			Assert(relabel->resulttypmod == -1);
+			Assert(IsBinaryCoercible(var->vartype, relabel->resulttype));
+			return var;
+		}
+		return NULL;
+	}
+}
+
+/*
+ * make_filter_param
+ *
+ *		Build the placeholder Param for a key-join proof filter.
+ *
+ *		These PARAM_KEYJOIN nodes are parser-private placeholders that may
+ *		appear only inside transient KeyJoinSurfaceFacts.filterConjuncts.
+ *		Accepted KeyJoinNodes carry only the dependencies consumed by the proof,
+ *		never these proof filter expressions.
+ */
+static Node *
+make_filter_param(KeyJoinKeyPosition *keypos, int pos)
+{
+	Param	   *param = makeNode(Param);
+
+	param->paramkind = PARAM_KEYJOIN;
+	param->paramid = pos + 1;
+	param->paramtype = keypos->eqTypeOid;
+	param->paramtypmod = keypos->eqTypmod;
+	param->paramcollid = keypos->collationOid;
+	param->location = -1;
+	return (Node *) param;
+}
+
+/*
+ * collect_filter_expr_dependencies_walker
+ *
+ *		Walker callback for collecting filter expression dependencies.
+ */
+static bool
+collect_filter_expr_dependencies_walker(Node *node, void *context_arg)
+{
+	List	  **dependencies = (List **) context_arg;
+
+	if (node == NULL)
+		return false;
+
+	if (IsA(node, OpExpr))
+	{
+		OpExpr	   *expr = castNode(OpExpr, node);
+
+		set_opfuncid(expr);
+		*dependencies = add_op_function_deps(*dependencies, expr->opno,
+											 expr->opfuncid);
+	}
+	else
+		(void) check_functions_in_node(node, filter_function_dependency_checker,
+									   context_arg);
+
+	return expression_tree_walker(node, collect_filter_expr_dependencies_walker,
+								  context_arg);
+}
+
+/*
+ * filter_function_dependency_checker
+ *
+ *		check_functions_in_node callback for collecting procedure dependencies.
+ */
+static bool
+filter_function_dependency_checker(Oid func_id, void *context_arg)
+{
+	List	  **dependencies = (List **) context_arg;
+
+	*dependencies = append_filter_dependency(*dependencies, ProcedureRelationId,
+											 func_id);
+	return false;
+}
+
+/*
+ * add_op_function_deps
+ *
+ *		Append an operator OID and its underlying function as dependencies.
+ */
+static List *
+add_op_function_deps(List *deps, Oid opno, Oid opfuncid)
+{
+	Assert(OidIsValid(opno));
+	Assert(OidIsValid(opfuncid));
+
+	deps = append_filter_dependency(deps, OperatorRelationId, opno);
+	return append_filter_dependency(deps, ProcedureRelationId, opfuncid);
+}
+
+/*
+ * append_filter_dependency
+ *
+ *		Append one filter dependency if the object OID is valid and new.
+ */
+static List *
+append_filter_dependency(List *dependencies, Oid classId, Oid objectId)
+{
+	return append_dependency_unique(dependencies, classId, objectId);
+}
+
+/*
+ * compute_join_output_facts
+ *
+ *		Project still-valid surface facts into a join result RTE.
+ *
+ *		Join type, key-join proof, referencing-side uniqueness, and filters
+ *		decide which facts survive.
+ */
+static void
+compute_join_output_facts(JoinExpr *j,
+						  Index left_rtindex, RangeTblEntry *left_rte,
+						  Index right_rtindex, RangeTblEntry *right_rte,
+						  RangeTblEntry *joinrte)
+{
+	KeyJoinSurfaceFacts *result;
+	List	  **lmap;
+	List	  **rmap;
+	KeyJoinNode *key_join_node;
+	bool		referencing_left;
+	bool		referenced_left;
+	RangeTblEntry *referencing_rte;
+	RangeTblEntry *referenced_rte;
+	List	  **referencing_map;
+	List	  **referenced_map;
+	Index		referencing_rtindex;
+	Index		referenced_rtindex;
+	bool		referenced_preserved;
+	bool		preserve_referencing_notnull;
+	bool		preserve_referenced_notnull;
+	bool		referencing_unique = false;
+	List	   *referencing_unique_deps = NIL;
+
+	Assert(left_rte != NULL);
+	Assert(right_rte != NULL);
+	Assert(joinrte->rtekind == RTE_JOIN);
+	Assert(j->keyJoin != NULL);
+
+	joinrte->keyJoinFacts = NULL;
+	result = makeNode(KeyJoinSurfaceFacts);
+
+	lmap = build_join_attrmap(joinrte, true,
+							  list_length(left_rte->eref->colnames));
+	rmap = build_join_attrmap(joinrte, false,
+							  list_length(right_rte->eref->colnames));
+
+	/* Accepted key joins can export facts for later key-join proofs. */
+	key_join_node = castNode(KeyJoinNode, j->keyJoin);
+	referencing_left = (key_join_node->referencingVarno == left_rtindex);
+	referenced_left = (key_join_node->referencedVarno == left_rtindex);
+	referencing_rte = referencing_left ? left_rte : right_rte;
+	referenced_rte = referenced_left ? left_rte : right_rte;
+	referencing_map = referencing_left ? lmap : rmap;
+	referenced_map = referenced_left ? lmap : rmap;
+	referencing_rtindex = referencing_left ? left_rtindex : right_rtindex;
+	referenced_rtindex = referenced_left ? left_rtindex : right_rtindex;
+	referenced_preserved = join_preserves_side(j->jointype, referenced_left);
+	preserve_referencing_notnull =
+		!join_null_extends_side(j->jointype, referencing_left);
+	preserve_referenced_notnull =
+		!join_null_extends_side(j->jointype, referenced_left);
+
+	Assert(key_join_node->referencingVarno == left_rtindex ||
+		   key_join_node->referencingVarno == right_rtindex);
+	Assert(key_join_node->referencedVarno == left_rtindex ||
+		   key_join_node->referencedVarno == right_rtindex);
+	Assert(key_join_node->referencingVarno != key_join_node->referencedVarno);
+
+	Assert(referencing_rte->keyJoinFactsComputed);
+	Assert(referenced_rte->keyJoinFactsComputed);
+	Assert(referencing_rte->keyJoinFacts != NULL);
+	Assert(referenced_rte->keyJoinFacts != NULL);
+
+	/*
+	 * Output-fact maintenance: detect referencing-side uniqueness
+	 * compatible with the accepted FK join predicate.
+	 */
+	{
+		KeyJoinSurfaceFacts *set = referencing_rte->keyJoinFacts;
+
+		foreach_node(KeyJoinFact, fkfact, set->facts)
+		{
+			List	   *fk_key_positions;
+
+			if (fkfact->kind != KJF_FOREIGN_KEY)
+				continue;
+			Assert(fkfact->active);
+			if (fkfact->constraint != key_join_node->constraint)
+				continue;
+			if (!select_key_position_parts(key_join_node->referencingAttnums,
+										   fkfact->keyPositions,
+										   NIL, NULL, &fk_key_positions))
+				continue;
+			foreach_node(KeyJoinFact, fact, set->facts)
+			{
+				bool		unique_matches = true;
+
+				if (fact->kind != KJF_UNIQUE)
+					continue;
+				if (!fact->active)
+					continue;
+
+				Assert(list_length(key_join_node->referencingAttnums) ==
+					   list_length(fk_key_positions));
+
+				/*
+				 * A referencing-side unique fact proves at-most-one match
+				 * only if it covers each referencing join column with the
+				 * same key identity as the FK positions selected for the
+				 * accepted join predicate.
+				 */
+				foreach_node(KeyJoinKeyPosition, keypos, fact->keyPositions)
+				{
+					ListCell   *lcattno;
+					ListCell   *lcfkpos;
+					bool		found = false;
+
+					forboth(lcattno, key_join_node->referencingAttnums,
+							lcfkpos, fk_key_positions)
+					{
+						KeyJoinKeyPosition *fkpos =
+							lfirst_node(KeyJoinKeyPosition, lcfkpos);
+
+						if (!list_member_int(keypos->attnums,
+											 lfirst_int(lcattno)))
+							continue;
+						if (!key_position_identity_equal(keypos, fkpos))
+						{
+							unique_matches = false;
+							break;
+						}
+						found = true;
+						break;
+					}
+					if (!found)
+						unique_matches = false;
+					if (!unique_matches)
+						break;
+				}
+
+				if (unique_matches)
+				{
+					referencing_unique = true;
+					referencing_unique_deps =
+						append_dependencies_unique(referencing_unique_deps,
+												   fact->dependencies);
+					break;
+				}
+			}
+			if (referencing_unique)
+				break;
+		}
+	}
+
+	/*
+	 * Project both input surfaces through the join output.  Null
+	 * extension can kill not-null facts; FK containment survives as
+	 * nullable containment, with not-null facts carrying condition 3.
+	 */
+	project_key_join_facts_from_rte(result, referencing_rte, referencing_map,
+									preserve_referencing_notnull, true, true,
+									join_filter_for_side(j->jointype,
+														 referencing_left,
+														 j->joinFilter),
+									NULL, NULL, referencing_rtindex, NIL, NIL,
+									KJI_NONE);
+	project_key_join_facts_from_rte(result, referenced_rte, referenced_map,
+									preserve_referenced_notnull,
+									referencing_unique,
+									referenced_preserved,
+									join_filter_for_side(j->jointype,
+														 referenced_left,
+														 j->joinFilter),
+									NULL, NULL, referenced_rtindex, NIL,
+									referencing_unique_deps,
+									KJI_JOIN_NOT_PRESERVED);
+
+	/*
+	 * Filter propagation: only safe when referenced side is fully
+	 * matched.  Filters may move only onto facts rooted in the FK's
+	 * referenced relation; FK output facts additionally match their own
+	 * constraint OID below.
+	 */
+	if (!referenced_preserved)
+	{
+		KeyJoinSurfaceFacts *rfacts = referencing_rte->keyJoinFacts;
+		KeyJoinSurfaceFacts *pfacts = referenced_rte->keyJoinFacts;
+
+		foreach_node(KeyJoinFact, source, rfacts->facts)
+		{
+			List	   *source_selected_base;
+			List	   *target_selected_base = NIL;
+
+			if (source->kind != KJF_FOREIGN_KEY)
+				continue;
+			Assert(source->active);
+			if (source->constraint != key_join_node->constraint ||
+				source->filterConjuncts == NIL)
+				continue;
+			Assert(list_length(source->baseAttnums) ==
+				   list_length(source->referencedAttnums));
+			if (!select_key_position_parts(key_join_node->referencingAttnums,
+										   source->keyPositions,
+										   source->baseAttnums,
+										   &source_selected_base, NULL))
+				continue;
+			foreach_int(srcbase, source_selected_base)
+			{
+				ListCell   *lcbase;
+				ListCell   *lcref;
+
+				forboth(lcbase, source->baseAttnums,
+						lcref, source->referencedAttnums)
+				{
+					if (lfirst_int(lcbase) == srcbase)
+					{
+						target_selected_base =
+							lappend_int(target_selected_base,
+										lfirst_int(lcref));
+						break;
+					}
+				}
+			}
+
+			Assert(list_length(target_selected_base) == list_length(source_selected_base));
+
+			/*
+			 * Match FK and RowCoverage targets to out-facts; the FK case
+			 * also requires constraint match so canonical filters can't
+			 * cross distinct FKs.
+			 */
+			foreach_node(KeyJoinFact, target, pfacts->facts)
+			{
+				List	   *projected_positions;
+				List	   *position_map;
+
+				if (target->kind != KJF_FOREIGN_KEY &&
+					target->kind != KJF_ROW_COVERAGE)
+					continue;
+				if (!target->active)
+					continue;
+				if (target->relid != source->referencedRelid)
+					continue;
+				projected_positions =
+					project_key_positions(target->keyPositions,
+										  referenced_map);
+				Assert(projected_positions != NIL);
+				position_map =
+					make_filter_position_map(source->baseAttnums,
+											 source_selected_base,
+											 target->baseAttnums,
+											 target_selected_base);
+
+				foreach_node(KeyJoinFact, out, result->facts)
+				{
+					if (out->kind != target->kind)
+						continue;
+					if (!out->active)
+						continue;
+					if (out->kind == KJF_FOREIGN_KEY)
+					{
+						if (out->constraint != target->constraint)
+							continue;
+					}
+					if (out->relid != target->relid)
+						continue;
+					if (!int_lists_same_members(out->baseAttnums,
+												target->baseAttnums))
+						continue;
+					if (!equal(out->keyPositions, projected_positions))
+						continue;
+
+					/*
+					 * Remap each canonical FK-side filter onto the output
+					 * fact.  Keep only filters that still constrain the
+					 * output key positions after projection.
+					 */
+					foreach_ptr(Node, conjunct, source->filterConjuncts)
+					{
+						Node	   *remapped;
+
+						if (!filter_conjunct_can_remap(conjunct,
+													   position_map))
+							continue;
+						remapped = remap_filter_conjunct(conjunct,
+														 position_map);
+						if (!filter_conjunct_matches_key_positions(remapped,
+																   out->keyPositions))
+							continue;
+
+						(void) collect_filter_expr_dependencies_walker(remapped,
+																	   &out->dependencies);
+						if (!list_contains_equal_node(out->filterConjuncts,
+													  remapped))
+							out->filterConjuncts =
+								lappend(out->filterConjuncts, remapped);
+					}
+				}
+			}
+		}
+	}
+	Assert(result->facts != NIL);
+	joinrte->keyJoinFacts = result;
+
+	pfree(lmap);
+	pfree(rmap);
+}
+
+/*
+ * build_join_attrmap
+ *
+ *		Build a mapping from one join input to JOIN output columns.
+ */
+static List **
+build_join_attrmap(RangeTblEntry *joinrte, bool leftside, int nattrs)
+{
+	List	  **attrmap = palloc0_array(List *, nattrs + 1);
+	List	   *joincols = leftside ? joinrte->joinleftcols :
+		joinrte->joinrightcols;
+
+	foreach_int(input_attno, joincols)
+	{
+		int			jcolno =
+			join_output_attno_for_input(joinrte, leftside,
+										foreach_current_index(input_attno) + 1);
+
+		Assert(input_attno > 0);
+		Assert(input_attno <= nattrs);
+		attrmap[input_attno] = lappend_int(attrmap[input_attno], jcolno);
+	}
+	return attrmap;
+}
+
+/*
+ * join_null_extends_side
+ *
+ *		Return true if the join type can null-extend the requested side.
+ */
+static bool
+join_null_extends_side(JoinType jointype, bool leftside)
+{
+	return jointype == JOIN_FULL ||
+		(leftside ? jointype == JOIN_RIGHT : jointype == JOIN_LEFT);
+}
+
+/*
+ * join_filter_for_side
+ *
+ *		Return the join filter applicable to facts projected from one side.
+ */
+static Node *
+join_filter_for_side(JoinType jointype, bool leftside, Node *filter)
+{
+	Assert(jointype == JOIN_INNER || jointype == JOIN_LEFT ||
+		   jointype == JOIN_RIGHT || jointype == JOIN_FULL);
+
+	if (jointype == JOIN_INNER)
+		return filter;
+	if (jointype == JOIN_LEFT)
+		return leftside ? NULL : filter;
+	if (jointype == JOIN_RIGHT)
+		return leftside ? filter : NULL;
+	return NULL;
+}
+
+/*
+ * storedNodeContainsKeyJoin
+ *
+ *		Report whether a stored query or expression tree contains a
+ *		KeyJoinNode.
+ */
+bool
+storedNodeContainsKeyJoin(Node *node)
+{
+	return stored_node_contains_key_join_walker(node, NULL);
+}
+
+/*
+ * stored_node_contains_key_join_walker
+ *
+ *		Walk stored query and expression nodes looking for KeyJoinNodes.
+ */
+static bool
+stored_node_contains_key_join_walker(Node *node, void *context)
+{
+	if (node == NULL)
+		return false;
+
+	if (IsA(node, KeyJoinNode))
+		return true;
+
+	if (IsA(node, JoinExpr))
+	{
+		JoinExpr   *join = castNode(JoinExpr, node);
+
+		if (stored_node_contains_key_join_walker(join->keyJoin, context))
+			return true;
+	}
+
+	if (IsA(node, Query))
+		return query_tree_walker(castNode(Query, node),
+								 stored_node_contains_key_join_walker,
+								 context, 0);
+
+	return expression_tree_walker(node, stored_node_contains_key_join_walker,
+								  context);
+}
+
+/*
+ * revalidatedStoredKeyJoinProofsAreSafe
+ *
+ *		Return true if a revalidated copy of a stored key-join tree can be
+ *		discarded without updating the stored tree or catalog dependencies.
+ *
+ *		A replayed proof may lose proof dependencies: stale extra pg_depend
+ *		entries are conservative and can be removed by explicitly recreating
+ *		the owning object.  It must not gain a new dependency or otherwise
+ *		change stored semantics, because then the stored object would be left
+ *		without a pg_depend edge for an object the proof now needs.
+ *
+ *		This may mutate the revalidated copy by restoring old dependency
+ *		lists before the final equality check.  Callers discard that copy.
+ */
+bool
+revalidatedStoredKeyJoinProofsAreSafe(Node *stored, Node *revalidated)
+{
+	List	   *stored_nodes;
+	List	   *revalidated_nodes;
+	ListCell   *lc1;
+	ListCell   *lc2;
+
+	if (equal(stored, revalidated))
+		return true;
+
+	stored_nodes = collect_key_join_nodes(stored);
+	revalidated_nodes = collect_key_join_nodes(revalidated);
+	Assert(list_length(stored_nodes) == list_length(revalidated_nodes));
+
+	forboth(lc1, stored_nodes, lc2, revalidated_nodes)
+	{
+		KeyJoinNode *old_node = castNode(KeyJoinNode, lfirst(lc1));
+		KeyJoinNode *new_node = castNode(KeyJoinNode, lfirst(lc2));
+
+		if (!dependency_list_is_subset(new_node->notNullConstraints,
+									   old_node->notNullConstraints))
+			return false;
+		if (!dependency_list_is_subset(new_node->proofDependencies,
+									   old_node->proofDependencies))
+			return false;
+
+		/*
+		 * Normalize away the only allowed difference: dependency shrinkage.
+		 * The revalidated copy is discarded by callers, so it is safe to
+		 * restore the old dependency lists before the final whole-tree check.
+		 */
+		new_node->notNullConstraints = old_node->notNullConstraints;
+		new_node->proofDependencies = old_node->proofDependencies;
+	}
+
+	/*
+	 * After normalization, every non-dependency field and every executable
+	 * expression must match the stored tree exactly.
+	 */
+	return equal(stored, revalidated);
+}
+
+/*
+ * collect_key_join_nodes
+ *
+ *		Return KeyJoinNodes in tree-walk order.
+ */
+static List *
+collect_key_join_nodes(Node *node)
+{
+	List	   *result = NIL;
+
+	(void) collect_key_join_nodes_walker(node, &result);
+	return result;
+}
+
+/*
+ * collect_key_join_nodes_walker
+ *
+ *		Walk stored query and expression nodes collecting KeyJoinNodes.
+ */
+static bool
+collect_key_join_nodes_walker(Node *node, void *context)
+{
+	List	  **result = (List **) context;
+
+	if (node == NULL)
+		return false;
+
+	if (IsA(node, KeyJoinNode))
+	{
+		*result = lappend(*result, node);
+		return false;
+	}
+
+	if (IsA(node, JoinExpr))
+	{
+		JoinExpr   *join = castNode(JoinExpr, node);
+
+		(void) collect_key_join_nodes_walker(join->keyJoin, context);
+	}
+
+	if (IsA(node, Query))
+		return query_tree_walker(castNode(Query, node),
+								 collect_key_join_nodes_walker,
+								 context, 0);
+
+	return expression_tree_walker(node, collect_key_join_nodes_walker,
+								  context);
+}
+
+/*
+ * dependency_list_is_subset
+ *
+ *		Return true if every dependency entry in candidate also appears in
+ *		superset.
+ */
+static bool
+dependency_list_is_subset(List *candidate, List *superset)
+{
+	foreach_node(KeyJoinProofDependency, dep, candidate)
+	{
+		if (!dependency_member(superset, dep->classId, dep->objectId))
+			return false;
+	}
+	return true;
+}
+
+/*
+ * revalidateStoredKeyJoinProofsInNode
+ *
+ *		Revalidate and rebuild key-join proofs contained in a copied stored
+ *		query or expression tree.
+ */
+void
+revalidateStoredKeyJoinProofsInNode(Node *node)
+{
+	(void) revalidate_stored_key_join_node_walker(node, NULL);
+}
+
+/*
+ * revalidate_stored_key_join_node_walker
+ *
+ *		Walk a stored query or expression tree and revalidate any Query nodes
+ *		with the supplied owning-query stack, if any.
+ */
+static bool
+revalidate_stored_key_join_node_walker(Node *node, void *context)
+{
+	if (node == NULL)
+		return false;
+
+	if (IsA(node, Query))
+	{
+		revalidate_stored_key_join_proofs_in_query(castNode(Query, node),
+												   (KeyJoinQueryStack *) context);
+		return false;
+	}
+
+	return expression_tree_walker(node, revalidate_stored_key_join_node_walker,
+								  context);
+}
+
+/*
+ * revalidate_stored_key_join_proofs_in_query
+ *
+ *		Revalidate one stored Query with an explicit stack of owning query
+ *		levels.
+ *
+ *		Stored key-join proofs may depend on CTE ownership, outer references,
+ *		and facts cached on RTEs.  This routine rebuilds that context for one
+ *		Query level, clears cached facts, recursively revalidates CTEs
+ *		and FROM-subqueries with the current Query as their parent frame, then
+ *		revalidates key joins in the jointree.  The final expression walker
+ *		handles expression subqueries and intentionally skips FROM/CTE
+ *		subqueries already handled in owner-aware passes above.
+ *
+ *		Demand-driven RTE projection depends on these owner-aware passes and
+ *		must not call back here for CTE or FROM-subquery bodies.
+ */
+static void
+revalidate_stored_key_join_proofs_in_query(Query *query,
+										   KeyJoinQueryStack *parent_stack)
+{
+	KeyJoinQueryStack qs;
+
+	Assert(query != NULL);
+
+	qs.parent = parent_stack;
+	qs.query = query;
+
+	/*
+	 * Stored query copies should not arrive with parser scratch facts already
+	 * attached.  Keep release builds robust if a caller accidentally reuses a
+	 * partially projected tree, but assert the one-shot replay contract.
+	 */
+	foreach_node(RangeTblEntry, rte, query->rtable)
+	{
+		Assert(!rte->keyJoinFactsComputed);
+		Assert(rte->keyJoinFacts == NULL);
+
+		rte->keyJoinFacts = NULL;
+		rte->keyJoinFactsComputed = false;
+	}
+
+	foreach_node(CommonTableExpr, cte, query->cteList)
+	{
+		revalidate_stored_key_join_proofs_in_query(castNode(Query, cte->ctequery),
+												   &qs);
+	}
+
+	foreach_node(RangeTblEntry, rte, query->rtable)
+	{
+		if (rte->rtekind == RTE_SUBQUERY)
+		{
+			Assert(rte->subquery != NULL);
+			revalidate_stored_key_join_proofs_in_query(rte->subquery, &qs);
+		}
+	}
+
+	revalidate_query_jointree_proofs(query, (Node *) query->jointree, &qs);
+
+	(void) query_tree_walker(query, revalidate_stored_key_join_node_walker,
+							 &qs,
+							 QTW_IGNORE_RT_SUBQUERIES |
+							 QTW_IGNORE_CTE_SUBQUERIES);
+}
+
+/*
+ * revalidate_query_jointree_proofs
+ *
+ *		Recompute join facts and revalidate stored KeyJoinNode proofs.
+ */
+static void
+revalidate_query_jointree_proofs(Query *query, Node *jtnode,
+								 KeyJoinQueryStack *query_stack)
+{
+	Assert(query != NULL);
+	Assert(jtnode != NULL);
+	Assert(query_stack != NULL);
+	Assert(query_stack->query == query);
+
+	if (IsA(jtnode, FromExpr))
+	{
+		foreach_ptr(Node, child, castNode(FromExpr, jtnode)->fromlist)
+			revalidate_query_jointree_proofs(query, child, query_stack);
+		return;
+	}
+	if (!IsA(jtnode, JoinExpr))
+		return;
+
+	{
+		JoinExpr   *j = castNode(JoinExpr, jtnode);
+		Index		left_rtindex = jtnode_surface_rtindex(j->larg);
+		Index		right_rtindex = jtnode_surface_rtindex(j->rarg);
+		RangeTblEntry *left_rte;
+		RangeTblEntry *right_rte;
+
+		revalidate_query_jointree_proofs(query, j->larg, query_stack);
+		revalidate_query_jointree_proofs(query, j->rarg, query_stack);
+
+		Assert(left_rtindex != 0);
+		Assert(right_rtindex != 0);
+		Assert(j->rtindex != 0);
+
+		left_rte = rt_fetch(left_rtindex, query->rtable);
+		right_rte = rt_fetch(right_rtindex, query->rtable);
+
+		if (j->keyJoin != NULL)
+		{
+			KeyJoinNode *key_join;
+			bool		referencing_left;
+			RangeTblEntry *referencing_rte;
+			RangeTblEntry *referenced_rte;
+			List	   *referenced_args;
+			List	   *referencing_args;
+			List	   *locations;
+			Node	   *key_quals = NULL;
+			int			nkeys;
+			KeyJoinMatch match;
+			KeyJoinFactContext context = {0};
+
+			key_join = castNode(KeyJoinNode, j->keyJoin);
+			referencing_left = (key_join->referencingVarno == left_rtindex);
+			referencing_rte = referencing_left ? left_rte : right_rte;
+			referenced_rte =
+				(key_join->referencedVarno == left_rtindex) ? left_rte : right_rte;
+			nkeys = list_length(key_join->referencingAttnums);
+
+			Assert(key_join->referencingVarno == left_rtindex ||
+				   key_join->referencingVarno == right_rtindex);
+			Assert(key_join->referencedVarno == left_rtindex ||
+				   key_join->referencedVarno == right_rtindex);
+			Assert(key_join->referencingVarno != key_join->referencedVarno);
+
+			context.query = query;
+			context.query_stack = query_stack;
+			context.revalidating_stored_query = true;
+			ensure_key_join_surface_facts(&context, referencing_rte);
+			ensure_key_join_surface_facts(&context, referenced_rte);
+
+			if (!find_key_join_match(referencing_rte, referenced_rte,
+									 key_join->referencingAttnums,
+									 key_join->referencedAttnums,
+									 !join_preserves_side(j->jointype,
+														  referencing_left),
+									 &match))
+			{
+				bool		local_is_referencing =
+					(key_join->direction == KEY_JOIN_RIGHT_ARROW);
+				RangeTblEntry *ref_alias_rte =
+					rt_fetch(key_join->refAliasVarno, query->rtable);
+				KeyJoinFailureSide referencing = {0};
+				KeyJoinFailureSide referenced = {0};
+
+				referencing.rte =
+					local_is_referencing ? right_rte : ref_alias_rte;
+				referencing.attnums =
+					local_is_referencing ? key_join->referencingAttnums :
+					key_join->refAliasAttnums;
+				referenced.rte =
+					local_is_referencing ? ref_alias_rte : right_rte;
+				referenced.attnums =
+					local_is_referencing ? key_join->refAliasAttnums :
+					key_join->referencedAttnums;
+				key_join_report_failure(NULL, -1, j->jointype, &match,
+										&referencing, &referenced);
+			}
+
+			/*
+			 * Join-local FILTER quals are kept separately on joinFilter and
+			 * are re-merged after the key equality is rebuilt.  Stored
+			 * non-filtered joins keep the key equality directly in quals;
+			 * filtered joins store quals as key equality AND joinFilter.
+			 */
+			if (j->joinFilter == NULL)
+				key_quals = j->quals;
+			else
+			{
+				BoolExpr   *andexpr;
+
+				Assert(j->quals != NULL);
+				andexpr = castNode(BoolExpr, j->quals);
+				Assert(andexpr->boolop == AND_EXPR);
+				Assert(list_length(andexpr->args) == 2);
+				Assert(equal(lsecond(andexpr->args), j->joinFilter));
+
+				key_quals = linitial(andexpr->args);
+			}
+			Assert(key_quals != NULL);
+
+			referenced_args = NIL;
+			referencing_args = NIL;
+			locations = NIL;
+
+			/*
+			 * The parser stores a single key equality as the OpExpr itself.
+			 * Multi-column key joins are stored as an AND whose arguments are
+			 * the key equalities in key-column order.
+			 */
+			if (nkeys == 1)
+				extract_key_join_qual_arg(key_quals, &referenced_args,
+										  &referencing_args, &locations);
+			else
+			{
+				BoolExpr   *andexpr;
+
+				andexpr = castNode(BoolExpr, key_quals);
+				Assert(andexpr->boolop == AND_EXPR);
+				Assert(list_length(andexpr->args) == nkeys);
+
+				foreach_ptr(Node, qual, andexpr->args)
+					extract_key_join_qual_arg(qual, &referenced_args,
+											  &referencing_args, &locations);
+			}
+
+			/*
+			 * Rebuild the executable equality quals from the stored argument
+			 * expressions and the freshly proven equality operators.  The
+			 * stored operators might no longer be the proof operators after
+			 * DDL, but the argument expressions preserve locations and parse
+			 * structure.
+			 */
+			key_quals = build_key_join_quals(referenced_args,
+											 referencing_args,
+											 match.eqoperators,
+											 match.eqtypes,
+											 match.eqtypmods,
+											 locations, -1);
+			j->quals = key_quals;
+			if (j->joinFilter != NULL)
+				j->quals = (Node *) makeBoolExpr(AND_EXPR,
+												 list_make2(j->quals,
+															copyObject(j->joinFilter)),
+												 -1);
+
+			key_join->constraint = match.constraint;
+			key_join->notNullConstraints = match.notnulldeps;
+			key_join->proofDependencies = match.proofdeps;
+		}
+	}
+}
+
+/*
+ * extract_key_join_qual_arg
+ *
+ *		Extract one stored key equality.  The parser constructs key equality
+ *		operators as referenced-column argument first, referencing-column
+ *		argument second.
+ */
+static void
+extract_key_join_qual_arg(Node *qual, List **referenced_args,
+						  List **referencing_args, List **locations)
+{
+	OpExpr	   *op;
+
+	Assert(qual != NULL);
+	op = castNode(OpExpr, qual);
+	Assert(list_length(op->args) == 2);
+
+	*referenced_args = lappend(*referenced_args,
+							   copyObject(linitial(op->args)));
+	*referencing_args = lappend(*referencing_args,
+								copyObject(lsecond(op->args)));
+	*locations = lappend_int(*locations, op->location);
+}
+
+static KeyJoinFact *
+add_fact(KeyJoinSurfaceFacts *set, KeyJoinFactKind kind)
+{
+	KeyJoinFact *fact = makeNode(KeyJoinFact);
+
+	fact->kind = kind;
+	fact->active = true;
+	fact->inactiveReason = KJI_NONE;
+	fact->inactiveOriginView = InvalidOid;
+	set->facts = lappend(set->facts, fact);
+	return fact;
+}
+
+/*
+ * add_paired_row_coverage
+ *
+ *		Append a row-coverage fact paired with the current unique or FK fact.
+ */
+static void
+add_paired_row_coverage(KeyJoinSurfaceFacts *set, List *keypositions,
+						Oid relid, List *baseAttnums, List *deps)
+{
+	KeyJoinFact *cov = add_fact(set, KJF_ROW_COVERAGE);
+
+	cov->keyPositions = keypositions;
+	cov->relid = relid;
+	cov->baseAttnums = baseAttnums;
+	cov->dependencies = deps;
+}
+
+/*
+ * key_join_collation_is_usable
+ *
+ *		Return true for noncollatable keys or deterministic collations.
+ */
+static bool
+key_join_collation_is_usable(Oid collationOid)
+{
+	if (!OidIsValid(collationOid))
+		return true;
+	return get_collation_isdeterministic(collationOid);
+}
+
+/*
+ * key_join_equality_identity_is_usable
+ *
+ *		Compute the typmod to use while executing equality against eqTypeOid.
+ *
+ *		The exposed proof identity remains exact, but some opclasses execute
+ *		equality through a binary-compatible input type.  In core this matters
+ *		for varchar_ops, whose btree opclass uses text equality.
+ */
+static bool
+key_join_equality_identity_is_usable(Oid typeOid, int32 typmod,
+									 Oid eqTypeOid, int32 *eqTypmod)
+{
+	int32		baseTypmod = typmod;
+	Oid			baseType;
+
+	Assert(eqTypmod != NULL);
+
+	baseType = key_join_equality_type(typeOid, typmod, &baseTypmod);
+	if (eqTypeOid == baseType)
+	{
+		*eqTypmod = baseTypmod;
+		return true;
+	}
+
+	if (baseType == VARCHAROID &&
+		eqTypeOid == TEXTOID)
+	{
+		Assert(IsBinaryCoercible(typeOid, eqTypeOid));
+		*eqTypmod = -1;
+		return true;
+	}
+
+	return false;
+}
+
+/*
+ * key_join_equality_operator_is_usable
+ *
+ *		Check whether an operator is usable as key-join equality.
+ *
+ *		The operator must be immutable, strict, boolean, non-set-returning,
+ *		and accept exactly one equality input type.  If expectedTypeOid is
+ *		valid, the operator must accept that exact type.
+ */
+static bool
+key_join_equality_operator_is_usable(Oid opno, Oid expectedTypeOid,
+									 Oid *eqTypeOid, List **dependencies)
+{
+	RegProcedure funcid;
+	HeapTuple	optup;
+	HeapTuple	proctup;
+	Form_pg_operator operform;
+	Form_pg_proc procform;
+	Oid			lefttype;
+
+	if (!OidIsValid(opno))
+		return false;
+
+	optup = lock_and_fetch_key_join_operator(opno);
+	operform = (Form_pg_operator) GETSTRUCT(optup);
+	lefttype = operform->oprleft;
+	funcid = operform->oprcode;
+
+	INJECTION_POINT("key-join-after-equality-operator-lock", NULL);
+
+	Assert(OidIsValid(lefttype));
+	Assert(lefttype == operform->oprright);
+	Assert(!OidIsValid(expectedTypeOid) || lefttype == expectedTypeOid);
+	Assert(operform->oprresult == BOOLOID);
+
+	Assert(RegProcedureIsValid(funcid));
+
+	proctup = lock_and_fetch_key_join_proc((Oid) funcid);
+	procform = (Form_pg_proc) GETSTRUCT(proctup);
+	if (procform->proretset ||
+		procform->provolatile != PROVOLATILE_IMMUTABLE ||
+		!procform->proisstrict)
+	{
+		ReleaseSysCache(proctup);
+		ReleaseSysCache(optup);
+		return false;
+	}
+
+	ReleaseSysCache(proctup);
+	ReleaseSysCache(optup);
+
+	Assert(dependencies != NULL);
+	Assert(eqTypeOid != NULL);
+	if (!dependency_member(*dependencies, OperatorRelationId, opno))
+		*dependencies = lappend(*dependencies,
+								make_dependency(OperatorRelationId, opno));
+	if (!dependency_member(*dependencies, ProcedureRelationId, (Oid) funcid))
+		*dependencies = lappend(*dependencies,
+								make_dependency(ProcedureRelationId,
+												(Oid) funcid));
+	*eqTypeOid = lefttype;
+	return true;
+}
+
+/*
+ * key_join_equality_type
+ *
+ *		Return the type identity used by equality operators for a key value.
+ *		Domains remain the exposed proof identity, but their equality
+ *		operators are resolved and executed on the base type.
+ */
+static Oid
+key_join_equality_type(Oid typeOid, int32 typmod, int32 *eqTypmod)
+{
+	int32		localTypmod = typmod;
+	Oid			result;
+
+	result = getBaseTypeAndTypmod(typeOid, &localTypmod);
+	Assert(eqTypmod != NULL);
+	*eqTypmod = localTypmod;
+	return result;
+}
+
+/*
+ * make_key_positions_from_attrnums
+ *
+ *		Build key-position descriptors for relation attribute numbers.
+ */
+static List *
+make_key_positions_from_attrnums(const TupleDesc tupdesc, const AttrNumber *attnums,
+								 int nattnums, const Oid *eqTypes,
+								 const int32 *eqTypmods,
+								 const Oid *eqOperators)
+{
+	List	   *result = NIL;
+
+	for (int i = 0; i < nattnums; i++)
+	{
+		Form_pg_attribute att;
+
+		Assert(attnums[i] > 0);
+		att = TupleDescAttr(tupdesc, attnums[i] - 1);
+		result = lappend(result,
+						 make_key_position(list_make1_int(att->attnum),
+										   att->atttypid, att->atttypmod,
+										   att->attcollation,
+										   eqTypes[i], eqTypmods[i],
+										   eqOperators[i]));
+	}
+	return result;
+}
+
+/*
+ * make_key_position
+ *
+ *		Build one KeyJoinKeyPosition node.
+ */
+static KeyJoinKeyPosition *
+make_key_position(List *attnums, Oid typeOid, int32 typmod,
+				  Oid collationOid, Oid eqTypeOid, int32 eqTypmod,
+				  Oid eqOperator)
+{
+	KeyJoinKeyPosition *pos = makeNode(KeyJoinKeyPosition);
+
+	pos->attnums = list_copy(attnums);
+	pos->typeOid = typeOid;
+	pos->typmod = typmod;
+	pos->collationOid = collationOid;
+	pos->eqTypeOid = eqTypeOid;
+	pos->eqTypmod = eqTypmod;
+	pos->eqOperator = eqOperator;
+	return pos;
+}
+
+static List *
+list_make_attrnums(const AttrNumber *attnums, int nattnums)
+{
+	List	   *result = NIL;
+
+	for (int i = 0; i < nattnums; i++)
+		result = lappend_int(result, attnums[i]);
+	return result;
+}
+
+/*
+ * add_inactive_projected
+ *
+ *		Carry a fact forward as inactive (diagnostics only), remapping its
+ *		columns through attrmap so a later rejection can still locate it.  An
+ *		active fact is stamped now (first death wins); an already-inactive fact
+ *		keeps its earlier stamp.  A fact whose columns the projection does not
+ *		carry is dropped.
+ */
+static void
+add_inactive_projected(KeyJoinSurfaceFacts *dst, KeyJoinFact *old,
+					   List **attrmap, KeyJoinInactiveReason reason)
+{
+	if (old->kind == KJF_NOT_NULL)
+	{
+		Assert(old->attnum > 0);
+		foreach_int(attno, attrmap[old->attnum])
+		{
+			KeyJoinFact *new = copyObject(old);
+
+			new->attnum = attno;
+			if (old->active)
+			{
+				new->active = false;
+				new->inactiveReason = reason;
+			}
+			dst->facts = lappend(dst->facts, new);
+		}
+	}
+	else
+	{
+		List	   *newpositions;
+		KeyJoinFact *new;
+
+		newpositions = project_key_positions(old->keyPositions, attrmap);
+		if (newpositions == NIL)
+			return;
+		new = copyObject(old);
+		new->keyPositions = newpositions;
+		if (old->active)
+		{
+			new->active = false;
+			new->inactiveReason = reason;
+		}
+		dst->facts = lappend(dst->facts, new);
+	}
+}
+
+/*
+ * key_join_failure_detail
+ *
+ *		Build the DETAIL for a rejected key join using relation aliases and
+ *		columns from the rejected FOR KEY clause.
+ */
+static char *
+key_join_failure_detail(KeyJoinReq req, bool inactivated,
+						KeyJoinInactiveReason reason, Oid origin_view,
+						const char *referencing_relcols,
+						const char *referenced_relcols,
+						const char *referencing_relation,
+						const char *referenced_relation,
+						const char *join_name)
+{
+	char	   *origin_view_name = NULL;
+	char	   *origin_sentence = NULL;
+	char	   *reason_sentence = NULL;
+	char	   *requirement_sentence;
+	StringInfoData detail;
+
+	if (OidIsValid(origin_view))
+	{
+		char	   *relname = get_rel_name(origin_view);
+		char	   *nspname = get_namespace_name(get_rel_namespace(origin_view));
+
+		Assert(relname != NULL);
+		Assert(nspname != NULL);
+		origin_view_name = quote_qualified_identifier(nspname, relname);
+		origin_sentence =
+			psprintf(_("The relevant operation occurs inside view %s."),
+					 origin_view_name);
+	}
+
+	Assert(req != REQ_NONE);
+
+	switch (req)
+	{
+		case REQ_FK:
+			requirement_sentence =
+				psprintf(_("There is no matching foreign key constraint for %s referencing %s."),
+						 referencing_relcols, referenced_relcols);
+			break;
+		case REQ_FKPAIR:
+			requirement_sentence =
+				psprintf(_("The matching foreign key for %s references different columns than %s."),
+						 referencing_relcols, referenced_relcols);
+			break;
+		case REQ_FILTER:
+		case REQ_COVERAGE:
+			requirement_sentence =
+				psprintf(_("Not every %s value can be proven to have a matching %s row."),
+						 referencing_relcols, referenced_relation);
+			break;
+		case REQ_UNIQUE:
+			requirement_sentence =
+				psprintf(_("Referenced columns %s are not proven unique."),
+						 referenced_relcols);
+			break;
+		default:
+			Assert(req == REQ_NOTNULL);
+			requirement_sentence =
+				psprintf(_("This %s could filter rows from %s."),
+						 join_name, referencing_relation);
+			break;
+	}
+
+	switch (req)
+	{
+		case REQ_FK:
+		case REQ_FKPAIR:
+			break;
+		case REQ_FILTER:
+			reason_sentence =
+				psprintf(_("Referenced relation %s has a filter that is not matched by referencing relation %s."),
+						 referenced_relation, referencing_relation);
+			break;
+		case REQ_UNIQUE:
+			if (inactivated)
+			{
+				/* KJI_JOIN_FANOUT is unique-specific. */
+				Assert(reason == KJI_JOIN_FANOUT);
+				reason_sentence =
+					psprintf(_("A preceding join may duplicate rows from referenced relation %s."),
+							 referenced_relation);
+			}
+			break;
+		case REQ_COVERAGE:
+			if (inactivated)
+			{
+				switch (reason)
+				{
+					case KJI_UNACCOUNTED_FILTER:
+						reason_sentence =
+							psprintf(_("Referenced relation %s is filtered before this key join."),
+									 referenced_relation);
+						break;
+					case KJI_JOIN_NOT_PRESERVED:
+						reason_sentence =
+							psprintf(_("Referenced relation %s may lose rows before this key join because of a preceding join."),
+									 referenced_relation);
+						break;
+					case KJI_ROW_REMOVING_CLAUSE:
+						reason_sentence =
+							psprintf(_("Referenced relation %s may lose rows before this key join because of HAVING, LIMIT, OFFSET, FOR UPDATE or TABLESAMPLE."),
+									 referenced_relation);
+						break;
+					case KJI_GROUP_BY:
+						reason_sentence =
+							psprintf(_("Referenced relation %s may lose rows before this key join because of GROUP BY."),
+									 referenced_relation);
+						break;
+					default:
+						Assert(reason == KJI_DISTINCT);
+						reason_sentence =
+							psprintf(_("Referenced relation %s may lose rows before this key join because of DISTINCT."),
+									 referenced_relation);
+						break;
+				}
+			}
+			else
+				reason_sentence =
+					psprintf(_("Referenced relation %s is not proven to contain every referenced key row."),
+							 referenced_relation);
+			break;
+		default:
+			Assert(req == REQ_NOTNULL);
+			if (inactivated)
+			{
+				Assert(reason == KJI_NULL_EXTENDING_JOIN);
+				reason_sentence =
+					psprintf(_("Referencing columns %s can be null because a preceding outer join can null-extend the referencing side."),
+							 referencing_relcols);
+			}
+			else
+				reason_sentence =
+					psprintf(_("Referencing columns %s can be null."),
+							 referencing_relcols);
+			break;
+	}
+
+	initStringInfo(&detail);
+	appendStringInfoString(&detail, requirement_sentence);
+	if (reason_sentence != NULL)
+	{
+		appendStringInfoChar(&detail, ' ');
+		appendStringInfoString(&detail, reason_sentence);
+	}
+	if (origin_sentence != NULL)
+	{
+		appendStringInfoChar(&detail, ' ');
+		appendStringInfoString(&detail, origin_sentence);
+	}
+	return detail.data;
+}
+
+/*
+ * key_join_report_failure
+ *
+ *		Format and report an unproven key join.  The two callers only decide
+ *		which query side is referencing/referenced; this function owns the
+ *		user-facing message shape.
+ */
+static void
+key_join_report_failure(ParseState *pstate, ParseLoc location,
+						JoinType jointype, const KeyJoinMatch *match,
+						const KeyJoinFailureSide *referencing,
+						const KeyJoinFailureSide *referenced)
+{
+	const KeyJoinFailureSide *sides[2] = {referencing, referenced};
+	const char *relations[2];
+	char	   *relcols[2];
+	char	   *detail;
+
+	for (int i = 0; i < 2; i++)
+	{
+		const KeyJoinFailureSide *side = sides[i];
+		StringInfoData colbuf;
+		bool		first = true;
+
+		Assert(side != NULL);
+		Assert((side->columns != NULL) != (side->rte != NULL));
+		Assert(side->columns == NULL || side->ncolumns > 0);
+		Assert(side->rte == NULL || side->attnums != NIL);
+
+		relations[i] = quote_identifier(side->alias != NULL ?
+										 side->alias :
+										 side->rte->eref->aliasname);
+
+		initStringInfo(&colbuf);
+		if (side->columns != NULL)
+		{
+			for (int j = 0; j < side->ncolumns; j++)
+			{
+				if (!first)
+					appendStringInfoString(&colbuf, ", ");
+				appendStringInfoString(&colbuf,
+									   quote_identifier(side->columns[j].name));
+				first = false;
+			}
+		}
+		else
+		{
+			foreach_int(attno, side->attnums)
+			{
+				char	   *colname = get_rte_attribute_name(side->rte, attno);
+
+				if (!first)
+					appendStringInfoString(&colbuf, ", ");
+				appendStringInfoString(&colbuf, quote_identifier(colname));
+				first = false;
+			}
+		}
+		relcols[i] = psprintf("%s (%s)", relations[i], colbuf.data);
+	}
+
+	detail =
+		key_join_failure_detail(match->failReq, match->failInactivated,
+								match->failReason, match->failOriginView,
+								relcols[0], relcols[1],
+								relations[0], relations[1],
+								jointype == JOIN_INNER ?
+								"inner join" : "join");
+
+	ereport(ERROR,
+			(errcode(ERRCODE_INVALID_FOREIGN_KEY),
+			 errmsg("key join from referencing relation %s to referenced relation %s cannot be proven",
+					relations[0], relations[1]),
+			 errdetail_internal("%s", detail),
+			 pstate != NULL ? parser_errposition(pstate, location) : 0));
+}
diff --git a/src/backend/rewrite/rewriteDefine.c b/src/backend/rewrite/rewriteDefine.c
index 6a223fbeaa4..aeda81ffbd6 100644
--- a/src/backend/rewrite/rewriteDefine.c
+++ b/src/backend/rewrite/rewriteDefine.c
@@ -163,6 +163,7 @@ InsertRule(const char *rulname,
 	 */
 	recordDependencyOnExpr(&myself, (Node *) action, NIL,
 						   DEPENDENCY_NORMAL);
+	recordDependencyOnKeyJoinProofs(&myself, (Node *) action);
 
 	if (event_qual != NULL)
 	{
@@ -172,6 +173,7 @@ InsertRule(const char *rulname,
 		qry = getInsertSelectQuery(qry, NULL);
 		recordDependencyOnExpr(&myself, event_qual, qry->rtable,
 							   DEPENDENCY_NORMAL);
+		recordDependencyOnKeyJoinProofs(&myself, event_qual);
 	}
 
 	/* Post creation hook for new rule */
diff --git a/src/backend/rewrite/rewriteManip.c b/src/backend/rewrite/rewriteManip.c
index 9aa7ef60475..6c4ed08c4b1 100644
--- a/src/backend/rewrite/rewriteManip.c
+++ b/src/backend/rewrite/rewriteManip.c
@@ -372,7 +372,8 @@ CombineRangeTables(List **dst_rtable, List **dst_perminfos,
  * Find all Var nodes in the given tree with varlevelsup == sublevels_up,
  * and increment their varno fields (rangetable indexes) by 'offset'.
  * The varnosyn fields are adjusted similarly.  Also, adjust other nodes
- * that contain rangetable indexes, such as RangeTblRef and JoinExpr.
+ * that contain rangetable indexes, such as RangeTblRef, JoinExpr and
+ * KeyJoinNode.
  *
  * NOTE: although this has the form of a walker, we cheat and modify the
  * nodes in-place.  The given expression tree should have been copied
@@ -427,6 +428,22 @@ OffsetVarNodes_walker(Node *node, OffsetVarNodes_context *context)
 
 		if (j->rtindex && context->sublevels_up == 0)
 			j->rtindex += context->offset;
+		OffsetVarNodes_walker(j->keyJoin, context);
+		/* fall through to examine children */
+	}
+	if (IsA(node, KeyJoinNode))
+	{
+		KeyJoinNode *kjn = (KeyJoinNode *) node;
+
+		if (context->sublevels_up == 0)
+		{
+			Assert(kjn->referencingVarno > 0);
+			Assert(kjn->referencedVarno > 0);
+			Assert(kjn->refAliasVarno > 0);
+			kjn->referencingVarno += context->offset;
+			kjn->referencedVarno += context->offset;
+			kjn->refAliasVarno += context->offset;
+		}
 		/* fall through to examine children */
 	}
 	if (IsA(node, PlaceHolderVar))
@@ -542,7 +559,8 @@ offset_relid_set(Relids relids, int offset)
  * Find all Var nodes in the given tree belonging to a specific relation
  * (identified by sublevels_up and rt_index), and change their varno fields
  * to 'new_index'.  The varnosyn fields are changed too.  Also, adjust other
- * nodes that contain rangetable indexes, such as RangeTblRef and JoinExpr.
+ * nodes that contain rangetable indexes, such as RangeTblRef, JoinExpr and
+ * KeyJoinNode.
  *
  * NOTE: although this has the form of a walker, we cheat and modify the
  * nodes in-place.  The given expression tree should have been copied
@@ -600,6 +618,22 @@ ChangeVarNodes_walker(Node *node, ChangeVarNodes_context *context)
 		if (context->sublevels_up == 0 &&
 			j->rtindex == context->rt_index)
 			j->rtindex = context->new_index;
+		ChangeVarNodes_walker(j->keyJoin, context);
+		/* fall through to examine children */
+	}
+	if (IsA(node, KeyJoinNode))
+	{
+		KeyJoinNode *kjn = (KeyJoinNode *) node;
+
+		if (context->sublevels_up == 0)
+		{
+			if (kjn->referencingVarno == context->rt_index)
+				kjn->referencingVarno = context->new_index;
+			if (kjn->referencedVarno == context->rt_index)
+				kjn->referencedVarno = context->new_index;
+			if (kjn->refAliasVarno == context->rt_index)
+				kjn->refAliasVarno = context->new_index;
+		}
 		/* fall through to examine children */
 	}
 	if (IsA(node, PlaceHolderVar))
diff --git a/src/backend/utils/adt/ruleutils.c b/src/backend/utils/adt/ruleutils.c
index 88de5c0481c..e09c0d4769f 100644
--- a/src/backend/utils/adt/ruleutils.c
+++ b/src/backend/utils/adt/ruleutils.c
@@ -12966,6 +12966,43 @@ get_from_clause(Query *query, const char *prefix, deparse_context *context)
 	}
 }
 
+static void
+get_key_join_col_list(StringInfo buf, Query *query, Index varno, List *attnums)
+{
+	RangeTblEntry *rte = rt_fetch(varno, query->rtable);
+	ListCell   *lc;
+	bool		first = true;
+
+	appendStringInfoChar(buf, '(');
+	foreach(lc, attnums)
+	{
+		int			attno = lfirst_int(lc);
+		char	   *colname;
+
+		if (!first)
+			appendStringInfoString(buf, ", ");
+		first = false;
+
+		Assert(attno > 0);
+		colname = get_rte_attribute_name(rte, attno);
+		appendStringInfoString(buf, quote_identifier(colname));
+	}
+	appendStringInfoChar(buf, ')');
+}
+
+static void
+get_join_filter_clause(Node *joinFilter, deparse_context *context)
+{
+	StringInfo	buf = context->buf;
+
+	if (joinFilter == NULL)
+		return;
+
+	appendStringInfoString(buf, " FILTER (WHERE ");
+	get_rule_expr(joinFilter, context, false);
+	appendStringInfoChar(buf, ')');
+}
+
 static void
 get_from_clause_item(Node *jtnode, Query *query, deparse_context *context)
 {
@@ -13213,7 +13250,41 @@ get_from_clause_item(Node *jtnode, Query *query, deparse_context *context)
 		if (need_paren_on_right)
 			appendStringInfoChar(buf, ')');
 
-		if (j->usingClause)
+		if (j->keyJoin && IsA(j->keyJoin, KeyJoinNode))
+		{
+			KeyJoinNode *key_join = castNode(KeyJoinNode, j->keyJoin);
+			Index		localvarno;
+			Index		refvarno;
+			List	   *localattnums;
+			List	   *refattnums;
+			char	   *refname;
+
+			if (key_join->direction == KEY_JOIN_RIGHT_ARROW)
+			{
+				localvarno = key_join->referencingVarno;
+				localattnums = key_join->referencingAttnums;
+			}
+			else
+			{
+				localvarno = key_join->referencedVarno;
+				localattnums = key_join->referencedAttnums;
+			}
+
+			refvarno = key_join->refAliasVarno;
+			refattnums = key_join->refAliasAttnums;
+			refname = get_rtable_name(refvarno, context);
+			Assert(refname != NULL);
+
+			appendStringInfoString(buf, " FOR KEY ");
+			get_key_join_col_list(buf, query, localvarno, localattnums);
+			appendStringInfo(buf, " %s %s ",
+							 key_join->direction == KEY_JOIN_RIGHT_ARROW ? "->" : "<-",
+							 quote_identifier(refname));
+			get_key_join_col_list(buf, query, refvarno, refattnums);
+
+			get_join_filter_clause(j->joinFilter, context);
+		}
+		else if (j->usingClause)
 		{
 			ListCell   *lc;
 			bool		first = true;
diff --git a/src/include/catalog/dependency.h b/src/include/catalog/dependency.h
index 2f3c1eae3c7..a0dc8fc2b08 100644
--- a/src/include/catalog/dependency.h
+++ b/src/include/catalog/dependency.h
@@ -31,6 +31,7 @@
 typedef enum DependencyType
 {
 	DEPENDENCY_NORMAL = 'n',
+	DEPENDENCY_KEYJOIN = 'k',
 	DEPENDENCY_AUTO = 'a',
 	DEPENDENCY_INTERNAL = 'i',
 	DEPENDENCY_PARTITION_PRI = 'P',
@@ -116,6 +117,9 @@ extern void recordDependencyOnExpr(const ObjectAddress *depender,
 								   Node *expr, List *rtable,
 								   DependencyType behavior);
 
+extern void recordDependencyOnKeyJoinProofs(const ObjectAddress *depender,
+											Node *expr);
+
 extern void collectDependenciesOfExpr(ObjectAddresses *addrs,
 									  Node *expr, List *rtable);
 
diff --git a/src/include/commands/keyjoin.h b/src/include/commands/keyjoin.h
new file mode 100644
index 00000000000..4d82c203eff
--- /dev/null
+++ b/src/include/commands/keyjoin.h
@@ -0,0 +1,23 @@
+/*-------------------------------------------------------------------------
+ *
+ * keyjoin.h
+ *	  prototypes for key-join command support.
+ *
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/commands/keyjoin.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef KEYJOIN_H
+#define KEYJOIN_H
+
+#include "catalog/objectaddress.h"
+
+extern void RevalidateDependentKeyJoinObjectsOnConstraint(Oid constraintOid);
+extern void RevalidateDependentKeyJoinObjectsOnRelation(Oid relationOid);
+extern void RevalidateDependentKeyJoinObjectsOnProcedure(Oid procOid);
+
+#endif							/* KEYJOIN_H */
diff --git a/src/include/nodes/parsenodes.h b/src/include/nodes/parsenodes.h
index 91377a6cde3..3d925ef25e3 100644
--- a/src/include/nodes/parsenodes.h
+++ b/src/include/nodes/parsenodes.h
@@ -1055,6 +1055,164 @@ typedef struct GraphElementPattern
 	ParseLoc	location;
 } GraphElementPattern;
 
+/*
+ * Key-join proof support nodes.
+ *
+ * KeyJoinSurfaceFacts is the parser's transient cache for FOR KEY proof
+ * facts, hung off RangeTblEntry.keyJoinFacts; the other types are its
+ * contents.  KeyJoinProofDependency is also embedded in stored KeyJoinNodes
+ * to record an accepted proof's catalog dependencies.
+ */
+
+/*
+ * KeyJoinProofDependency -
+ *	  catalog object that a key-join proof depends on
+ *
+ * ObjectAddress-like, but a Node so it can be embedded in query trees and
+ * recorded as DEPENDENCY_KEYJOIN for stored parse trees.  Key-join proofs
+ * depend only on whole objects (relations, constraints, operators, and
+ * functions), so there is no objectSubId field.
+ */
+typedef struct KeyJoinProofDependency
+{
+	NodeTag		type;
+	Oid			classId;
+	Oid			objectId;
+} KeyJoinProofDependency;
+
+/*
+ * KeyJoinKeyPosition -
+ *	  position of a key participating in a key-join proof
+ *
+ * A position may list multiple attnums when projection exposes the same
+ * key component through multiple direct aliases.
+ *
+ * (typeOid, typmod, collationOid) is the surface Var type the position
+ * exposes; the prover asserts it matches the corresponding Var's vartype,
+ * vartypmod and collationOid exactly.
+ *
+ * (eqTypeOid, eqTypmod, eqOperator) describes the equality operator used at
+ * this position and the type it takes as input; these may differ from the
+ * surface type when equality is run on a domain.
+ */
+typedef struct KeyJoinKeyPosition
+{
+	NodeTag		type;
+	List	   *attnums;		/* attnums forming this position */
+
+	/* Surface Var type the position exposes: */
+	Oid			typeOid;
+	int32		typmod;
+	Oid			collationOid;
+
+	/* Equality operator and its input type at this position: */
+	Oid			eqTypeOid;
+	int32		eqTypmod;
+	Oid			eqOperator;
+} KeyJoinKeyPosition;
+
+/*
+ * KeyJoinFactKind -
+ *	  what kind of key-join proof fact a KeyJoinFact represents
+ *
+ * KJF_UNIQUE and KJF_FOREIGN_KEY use SQL null semantics: they prove
+ * uniqueness and containment for all-non-null key values.  KJF_ROW_COVERAGE
+ * likewise tracks coverage of all-non-null referenced key values, not literal
+ * preservation of every nullable referenced row.  KJF_NOT_NULL is separate
+ * evidence consumed only when a key join might otherwise discard null
+ * referencing rows.
+ */
+typedef enum KeyJoinFactKind
+{
+	KJF_NOT_NULL,				/* attnum is proven non-null */
+	KJF_UNIQUE,					/* all-non-null keyPositions are unique */
+	KJF_FOREIGN_KEY,			/* baseAttnums reference referencedAttnums */
+	KJF_ROW_COVERAGE,			/* all-non-null relid key values reach here */
+} KeyJoinFactKind;
+
+/*
+ * KeyJoinInactiveReason -
+ *	  why a transient proof fact failed to survive propagation to a surface
+ *
+ * Diagnostic-only.  When a propagation step would have discarded a fact, the
+ * fact is instead retained with active=false and stamped with the reason
+ * here, so that a later rejection can explain itself.  KJI_NONE is set on
+ * every active fact.
+ */
+typedef enum KeyJoinInactiveReason
+{
+	KJI_NONE = 0,
+	KJI_NULL_EXTENDING_JOIN,	/* outer join null-extends a side */
+	KJI_JOIN_NOT_PRESERVED,		/* join does not retain all referenced rows */
+	KJI_JOIN_FANOUT,			/* join may match a referenced row more than once */
+	KJI_ROW_REMOVING_CLAUSE,	/* HAVING / LIMIT / OFFSET / FOR UPDATE / TABLESAMPLE */
+	KJI_GROUP_BY,				/* row coverage lost by GROUP BY */
+	KJI_DISTINCT,				/* row coverage lost by DISTINCT */
+	KJI_UNACCOUNTED_FILTER,		/* a filter could not be carried with the fact */
+} KeyJoinInactiveReason;
+
+/*
+ * KeyJoinFact -
+ *	  transient key-join proof fact attached to an RTE surface
+ *
+ * The parser attaches these to RangeTblEntry.keyJoinFacts while proving FOR
+ * KEY joins; stored KeyJoinNodes retain only the dependencies they actually
+ * consumed.  The kind field discriminates which sub-case of fields below is
+ * meaningful.
+ */
+typedef struct KeyJoinFact
+{
+	NodeTag		type;
+	KeyJoinFactKind kind;
+
+	/* KJF_NOT_NULL only: */
+	AttrNumber	attnum;			/* surface column attnum */
+
+	/*
+	 * KJF_UNIQUE, KJF_FOREIGN_KEY, KJF_ROW_COVERAGE.  relid is normally the
+	 * base relation supplying the proof; it is InvalidOid for KJF_UNIQUE
+	 * facts derived from GROUP BY / DISTINCT.  baseAttnums has one entry per
+	 * keyPositions entry: base relation attnums for relation-level facts,
+	 * target-list resnos for GROUP BY / DISTINCT facts.
+	 */
+	List	   *keyPositions;	/* list of KeyJoinKeyPosition */
+	Oid			relid;
+	List	   *baseAttnums;
+
+	/* KJF_FOREIGN_KEY only: */
+	Oid			referencedRelid;
+	List	   *referencedAttnums;
+	Oid			constraint;
+
+	/* KJF_FOREIGN_KEY and KJF_ROW_COVERAGE: */
+	List	   *filterConjuncts;	/* list of expression trees */
+
+	/* All kinds: */
+	List	   *dependencies;	/* list of KeyJoinProofDependency */
+
+	/*
+	 * Diagnostic carry-over of discarded facts.  A fact starts active; if a
+	 * propagation step would have discarded it, the fact is instead retained
+	 * with active=false so a later rejection can point at it.
+	 */
+	bool		active;
+	KeyJoinInactiveReason inactiveReason;
+	Oid			inactiveOriginView;
+} KeyJoinFact;
+
+/*
+ * KeyJoinSurfaceFacts -
+ *	  cached proof facts for one RTE surface
+ *
+ * Transient parser scratch, never read from stored query trees.  See
+ * RangeTblEntry.keyJoinFacts.
+ */
+typedef struct KeyJoinSurfaceFacts
+{
+	NodeTag		type;
+	List	   *facts;			/* list of KeyJoinFact */
+} KeyJoinSurfaceFacts;
+
 /****************************************************************************
  *	Nodes for a Query tree
  ****************************************************************************/
@@ -1364,6 +1522,14 @@ typedef struct RangeTblEntry
 	bool		inFromCl pg_node_attr(query_jumble_ignore);
 	/* security barrier quals to apply, if any */
 	List	   *securityQuals pg_node_attr(query_jumble_ignore);
+
+	/*
+	 * Transient proof caches for FOR KEY joins, populated lazily during live
+	 * parse analysis or stored-query revalidation.  Never read from stored
+	 * query trees.
+	 */
+	bool		keyJoinFactsComputed pg_node_attr(equal_ignore, query_jumble_ignore, read_write_ignore, read_as(false));
+	KeyJoinSurfaceFacts *keyJoinFacts pg_node_attr(equal_ignore, query_jumble_ignore, read_write_ignore, read_as(NULL));
 } RangeTblEntry;
 
 /*
@@ -2164,6 +2330,75 @@ typedef struct JsonArrayAgg
 	bool		absent_on_null; /* skip NULL elements? */
 } JsonArrayAgg;
 
+/*
+ * KeyJoinClause -
+ *	  raw parser representation of a FOR KEY join clause
+ *
+ * Produced by the grammar for "FOR KEY (cols) <- alias (cols) [FILTER ...]"
+ * and the matching "->" form, used as the condition of a JOIN.  The first
+ * name list is unqualified and resolves against the JOIN's right operand;
+ * the alias after the arrow must name a table reference visible inside the
+ * left operand, and the second name list resolves against that reference.
+ *
+ * Transformed into a KeyJoinNode during parse analysis once the referencing
+ * and referenced columns and the underlying foreign-key constraint have been
+ * resolved.
+ */
+typedef struct KeyJoinClause
+{
+	NodeTag		type;
+	List	   *localCols;		/* column names to resolve in right operand */
+	KeyJoinDirection direction; /* arrow direction; selects which side is FK */
+	char	   *refAlias;		/* table alias in left operand */
+	List	   *refCols;		/* column names to resolve with refAlias */
+	Node	   *filter;			/* raw FILTER (WHERE ...) expr, or NULL */
+	ParseLoc	location;		/* location of the FOR KEY token, or -1 */
+} KeyJoinClause;
+
+/*
+ * KeyJoinNode -
+ *	  analyzed representation of a FOR KEY join
+ *
+ * Produced by parse analysis from a KeyJoinClause once the referencing and
+ * referenced columns and supporting catalog objects have been resolved.
+ * Stored on JoinExpr.keyJoin so stored-object revalidation can replay the
+ * proof against the current catalog state and so the deparser can reproduce
+ * the original FOR KEY syntax.
+ *
+ * referencingVarno/referencingAttnums and referencedVarno/referencedAttnums
+ * identify the referencing and referenced sides of the proof regardless of
+ * which side appeared on which side of the arrow in source.  refAliasVarno
+ * and refAliasAttnums preserve which table reference was named after the
+ * arrow in source, so the deparser can print that name and column list after
+ * the arrow.
+ */
+typedef struct KeyJoinNode
+{
+	NodeTag		type;
+	KeyJoinDirection direction; /* arrow direction in source; for deparse/errors */
+	Index		referencingVarno;	/* rtindex of the referencing RTE */
+	Index		referencedVarno;	/* rtindex of the referenced RTE */
+
+	/* Paired 1:1, in proof-matched order: */
+	List	   *referencingAttnums;
+	List	   *referencedAttnums;
+
+	/* The table reference named after the arrow, preserved in source order: */
+	Index		refAliasVarno;
+	List	   *refAliasAttnums;
+
+	Oid			constraint;		/* pg_constraint OID of the FK */
+
+	/*
+	 * Catalog dependencies the proof relies on.  proofDependencies is the
+	 * complete deduplicated set: FK, uniqueness, row-coverage, and not-null
+	 * evidence.  notNullConstraints is the not-null subset, duplicated here
+	 * so condition-3 evidence remains identifiable during revalidation.
+	 */
+	List	   *notNullConstraints;
+	List	   *proofDependencies;
+} KeyJoinNode;
+
 
 /*****************************************************************************
  *		Raw Grammar Output Statements
diff --git a/src/include/nodes/primnodes.h b/src/include/nodes/primnodes.h
index 7977ee24783..d8d7371c996 100644
--- a/src/include/nodes/primnodes.h
+++ b/src/include/nodes/primnodes.h
@@ -379,6 +379,11 @@ typedef struct Const
  *				of the `paramid' field contain the SubLink's subLinkId, and
  *				the low-order 16 bits contain the column number.  (This type
  *				of Param is also converted to PARAM_EXEC during planning.)
+ *
+ *		PARAM_KEYJOIN:  The parameter represents a key position in a transient
+ *				key-join proof filter.  It is parser-private scratch data and
+ *				must not reach planning or execution.  Such parameters are
+ *				numbered from 1 to n.
  */
 typedef enum ParamKind
 {
@@ -386,6 +391,7 @@ typedef enum ParamKind
 	PARAM_EXEC,
 	PARAM_SUBLINK,
 	PARAM_MULTIEXPR,
+	PARAM_KEYJOIN,
 } ParamKind;
 
 typedef struct Param
@@ -2334,14 +2340,23 @@ typedef struct RangeTblRef
 	int			rtindex;
 } RangeTblRef;
 
+typedef enum KeyJoinDirection
+{
+	KEY_JOIN_LEFT_ARROW,
+	KEY_JOIN_RIGHT_ARROW
+} KeyJoinDirection;
+
 /*----------
  * JoinExpr - for SQL JOIN expressions
  *
- * isNatural, usingClause, and quals are interdependent.  The user can write
- * only one of NATURAL, USING(), or ON() (this is enforced by the grammar).
+ * isNatural, usingClause, keyJoin, and quals are interdependent.  The user can
+ * write only one of NATURAL, USING(), FOR KEY, or ON() (this is enforced by
+ * the grammar).
  * If he writes NATURAL then parse analysis generates the equivalent USING()
  * list, and from that fills in "quals" with the right equality comparisons.
  * If he writes USING() then "quals" is filled with equality comparisons.
+ * If he writes FOR KEY then "keyJoin" is set initially, and parse analysis
+ * replaces it while filling "quals" with equality comparisons.
  * If he writes ON() then only "quals" is set.  Note that NATURAL/USING
  * are not equivalent to ON() since they also affect the output column list.
  *
@@ -2373,6 +2388,10 @@ typedef struct JoinExpr
 	List	   *usingClause pg_node_attr(query_jumble_ignore);
 	/* alias attached to USING clause, if any */
 	Alias	   *join_using_alias pg_node_attr(query_jumble_ignore);
+	/* KEY clause, if any */
+	Node	   *keyJoin pg_node_attr(query_jumble_ignore);
+	/* FILTER clause (transformed); for deparsing only, actual expr is in quals */
+	Node	   *joinFilter pg_node_attr(query_jumble_ignore);
 	/* qualifiers on join, if any */
 	Node	   *quals;
 	/* user-written alias clause, if any */
diff --git a/src/include/parser/parse_key_join.h b/src/include/parser/parse_key_join.h
new file mode 100644
index 00000000000..b2a9894d689
--- /dev/null
+++ b/src/include/parser/parse_key_join.h
@@ -0,0 +1,27 @@
+/*-------------------------------------------------------------------------
+ *
+ * parse_key_join.h
+ *	  Handle key joins in parser
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/parser/parse_key_join.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PARSE_KEY_JOIN_H
+#define PARSE_KEY_JOIN_H
+
+#include "parser/parse_node.h"
+
+extern void transformAndValidateKeyJoin(ParseState *pstate, JoinExpr *j,
+										ParseNamespaceItem *l_nsitem,
+										ParseNamespaceItem *r_nsitem,
+										List *l_namespace);
+extern bool storedNodeContainsKeyJoin(Node *node);
+extern void revalidateStoredKeyJoinProofsInNode(Node *node);
+extern bool revalidatedStoredKeyJoinProofsAreSafe(Node *stored,
+												  Node *revalidated);
+
+#endif							/* PARSE_KEY_JOIN_H */
diff --git a/src/pl/plpgsql/src/pl_gram.y b/src/pl/plpgsql/src/pl_gram.y
index 5009e59a78f..b3bc070a71b 100644
--- a/src/pl/plpgsql/src/pl_gram.y
+++ b/src/pl/plpgsql/src/pl_gram.y
@@ -247,6 +247,7 @@ static	void			check_raise_parameters(PLpgSQL_stmt_raise *stmt);
 %token <ival>	ICONST PARAM
 %token			TYPECAST DOT_DOT COLON_EQUALS EQUALS_GREATER
 %token			LESS_EQUALS GREATER_EQUALS NOT_EQUALS
+%token			RIGHT_ARROW
 
 /*
  * Other tokens recognized by plpgsql's lexer interface layer (pl_scanner.c).
diff --git a/src/test/isolation/expected/key-join-function-race.out b/src/test/isolation/expected/key-join-function-race.out
new file mode 100644
index 00000000000..b00a44e8abd
--- /dev/null
+++ b/src/test/isolation/expected/key-join-function-race.out
@@ -0,0 +1,39 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_alter s2_create_view s1_commit
+step s1_begin: BEGIN;
+step s1_alter: 
+ CREATE OR REPLACE FUNCTION key_join_function_race.f() RETURNS int
+     LANGUAGE sql VOLATILE AS 'SELECT 1';
+
+step s2_create_view: 
+ CREATE VIEW key_join_function_race.v AS
+ SELECT v_c.cid, v_p.id
+ FROM key_join_function_race.v_p
+ JOIN key_join_function_race.v_c
+     FOR KEY (region, parent_id) -> v_p (region, id);
+ <waiting ...>
+step s1_commit: COMMIT;
+step s2_create_view: <... completed>
+ERROR:  key join from referencing relation v_c to referenced relation v_p cannot be proven
+
+starting permutation: s1_begin s1_lock_g s2_replace_g s1_drop_g s1_commit
+step s1_begin: BEGIN;
+step s1_lock_g: ALTER FUNCTION key_join_function_race.g() COST 1;
+step s2_replace_g: 
+ CREATE OR REPLACE FUNCTION key_join_function_race.g() RETURNS int
+     LANGUAGE sql STABLE AS 'SELECT 2';
+ <waiting ...>
+step s1_drop_g: DROP FUNCTION key_join_function_race.g();
+step s1_commit: COMMIT;
+step s2_replace_g: <... completed>
+ERROR:  function "g" was concurrently dropped
+
+starting permutation: s1_begin s1_lock_h s2_alter_h s1_drop_h s1_commit
+step s1_begin: BEGIN;
+step s1_lock_h: ALTER FUNCTION key_join_function_race.h() COST 1;
+step s2_alter_h: ALTER FUNCTION key_join_function_race.h() COST 2; <waiting ...>
+step s1_drop_h: DROP FUNCTION key_join_function_race.h();
+step s1_commit: COMMIT;
+step s2_alter_h: <... completed>
+ERROR:  function "key_join_function_race.h" was concurrently dropped
diff --git a/src/test/isolation/expected/key-join-lazy-facts-lock.out b/src/test/isolation/expected/key-join-lazy-facts-lock.out
new file mode 100644
index 00000000000..6df6189bac4
--- /dev/null
+++ b/src/test/isolation/expected/key-join-lazy-facts-lock.out
@@ -0,0 +1,11 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_lock s2_select_child s1_commit
+step s1_lock: BEGIN; LOCK TABLE key_join_lazy_facts_lock.p IN ACCESS EXCLUSIVE MODE;
+step s2_select_child: SELECT id, parent_id FROM key_join_lazy_facts_lock.c;
+id|parent_id
+--+---------
+10|        1
+(1 row)
+
+step s1_commit: COMMIT;
diff --git a/src/test/isolation/expected/key-join-plain-function-alter-owner-lock.out b/src/test/isolation/expected/key-join-plain-function-alter-owner-lock.out
new file mode 100644
index 00000000000..35e92409807
--- /dev/null
+++ b/src/test/isolation/expected/key-join-plain-function-alter-owner-lock.out
@@ -0,0 +1,33 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_alter_f s2_auth s2_alter_f s1_commit
+step s1_begin: BEGIN;
+step s1_alter_f: 
+ ALTER FUNCTION key_join_plain_function_alter_owner_lock.f() COST 11;
+
+step s2_auth: SET ROLE key_join_plain_function_alter_other;
+step s2_alter_f: 
+ ALTER FUNCTION key_join_plain_function_alter_owner_lock.f() COST 12;
+
+ERROR:  must be owner of function key_join_plain_function_alter_owner_lock.f
+step s1_commit: COMMIT;
+
+starting permutation: s1_begin s1_alter_g s2_auth_owner s2_alter_g s1_owner_g s1_commit
+step s1_begin: BEGIN;
+step s1_alter_g: 
+ ALTER FUNCTION key_join_plain_function_alter_owner_lock.g() COST 21;
+
+step s2_auth_owner: 
+ RESET ROLE;
+ SET ROLE key_join_plain_function_alter_owner;
+
+step s2_alter_g: 
+ ALTER FUNCTION key_join_plain_function_alter_owner_lock.g() COST 22;
+ <waiting ...>
+step s1_owner_g: 
+ ALTER FUNCTION key_join_plain_function_alter_owner_lock.g()
+     OWNER TO key_join_plain_function_alter_other;
+
+step s1_commit: COMMIT;
+step s2_alter_g: <... completed>
+ERROR:  must be owner of function key_join_plain_function_alter_owner_lock.g
diff --git a/src/test/isolation/expected/key-join-plain-function-dependent-lock.out b/src/test/isolation/expected/key-join-plain-function-dependent-lock.out
new file mode 100644
index 00000000000..6aa36d806ad
--- /dev/null
+++ b/src/test/isolation/expected/key-join-plain-function-dependent-lock.out
@@ -0,0 +1,7 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_alter_g s2_alter_f s1_commit
+step s1_begin: BEGIN;
+step s1_alter_g: ALTER FUNCTION key_join_plain_function_dependent_lock.g() COST 11;
+step s2_alter_f: ALTER FUNCTION key_join_plain_function_dependent_lock.f() COST 12;
+step s1_commit: COMMIT;
diff --git a/src/test/isolation/expected/key-join-plain-function-ownercheck-lock.out b/src/test/isolation/expected/key-join-plain-function-ownercheck-lock.out
new file mode 100644
index 00000000000..6727aaa1dd2
--- /dev/null
+++ b/src/test/isolation/expected/key-join-plain-function-ownercheck-lock.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_alter_f s2_auth s2_replace_f s1_commit
+step s1_begin: BEGIN;
+step s1_alter_f: 
+ ALTER FUNCTION key_join_plain_function_ownercheck_lock.f() COST 11;
+
+step s2_auth: SET ROLE key_join_plain_function_other;
+step s2_replace_f: 
+ CREATE OR REPLACE FUNCTION key_join_plain_function_ownercheck_lock.f()
+ RETURNS int
+ LANGUAGE sql
+ RETURN 2;
+
+ERROR:  must be owner of function f
+step s1_commit: COMMIT;
+
+starting permutation: s1_begin s1_alter_f s2_auth_owner s2_replace_f s1_owner_f s1_commit
+step s1_begin: BEGIN;
+step s1_alter_f: 
+ ALTER FUNCTION key_join_plain_function_ownercheck_lock.f() COST 11;
+
+step s2_auth_owner: SET ROLE key_join_plain_function_owner;
+step s2_replace_f: 
+ CREATE OR REPLACE FUNCTION key_join_plain_function_ownercheck_lock.f()
+ RETURNS int
+ LANGUAGE sql
+ RETURN 2;
+ <waiting ...>
+step s1_owner_f: 
+ ALTER FUNCTION key_join_plain_function_ownercheck_lock.f()
+ OWNER TO key_join_plain_function_other;
+
+step s1_commit: COMMIT;
+step s2_replace_f: <... completed>
+ERROR:  must be owner of function f
diff --git a/src/test/isolation/expected/key-join-plain-function-prelock.out b/src/test/isolation/expected/key-join-plain-function-prelock.out
new file mode 100644
index 00000000000..6c6ba563d4b
--- /dev/null
+++ b/src/test/isolation/expected/key-join-plain-function-prelock.out
@@ -0,0 +1,7 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_alter_f s2_alter_g s1_commit
+step s1_begin: BEGIN;
+step s1_alter_f: ALTER FUNCTION key_join_plain_function_prelock.f() COST 11;
+step s2_alter_g: ALTER FUNCTION key_join_plain_function_prelock.g() COST 12;
+step s1_commit: COMMIT;
diff --git a/src/test/isolation/expected/key-join-plain-relation-dependent-lock.out b/src/test/isolation/expected/key-join-plain-relation-dependent-lock.out
new file mode 100644
index 00000000000..bce84128fb9
--- /dev/null
+++ b/src/test/isolation/expected/key-join-plain-relation-dependent-lock.out
@@ -0,0 +1,13 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_enable_rls s2_alter_view s1_commit
+step s1_begin: BEGIN;
+step s1_enable_rls: 
+ ALTER TABLE key_join_plain_relation_dependent_lock.t
+     ENABLE ROW LEVEL SECURITY;
+
+step s2_alter_view: 
+ ALTER VIEW key_join_plain_relation_dependent_lock.v
+     SET (security_barrier=true);
+
+step s1_commit: COMMIT;
diff --git a/src/test/isolation/expected/key-join-plain-view-rls-lock.out b/src/test/isolation/expected/key-join-plain-view-rls-lock.out
new file mode 100644
index 00000000000..c8b25c01240
--- /dev/null
+++ b/src/test/isolation/expected/key-join-plain-view-rls-lock.out
@@ -0,0 +1,13 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_lock_view s2_enable_rls s1_commit
+step s1_lock_view: 
+ BEGIN;
+ ALTER VIEW key_join_plain_view_rls_lock.v
+     SET (security_barrier=true);
+
+step s2_enable_rls: 
+ ALTER TABLE key_join_plain_view_rls_lock.t
+     ENABLE ROW LEVEL SECURITY;
+
+step s1_commit: COMMIT;
diff --git a/src/test/isolation/expected/key-join-policy-function-deadlock.out b/src/test/isolation/expected/key-join-policy-function-deadlock.out
new file mode 100644
index 00000000000..19d91baa7ab
--- /dev/null
+++ b/src/test/isolation/expected/key-join-policy-function-deadlock.out
@@ -0,0 +1,25 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_lock_table s2_alter_function s1_alter_policy s1_rollback
+step s1_lock_table: 
+ BEGIN;
+ SET deadlock_timeout = '10ms';
+ LOCK TABLE key_join_policy_function_deadlock.guarded IN ACCESS EXCLUSIVE MODE;
+
+step s2_alter_function: 
+ SET deadlock_timeout = '10s';
+ ALTER FUNCTION key_join_policy_function_deadlock.f() VOLATILE;
+
+step s1_alter_policy: 
+ ALTER POLICY pol ON key_join_policy_function_deadlock.guarded
+ USING (EXISTS (
+     SELECT 1
+     FROM (SELECT * FROM key_join_policy_function_deadlock.p
+           WHERE region = key_join_policy_function_deadlock.f()) p
+     JOIN (SELECT * FROM key_join_policy_function_deadlock.c
+           WHERE region = key_join_policy_function_deadlock.f()) c
+         FOR KEY (region, parent_id) -> p (region, id)
+ ));
+
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+step s1_rollback: ROLLBACK;
diff --git a/src/test/isolation/expected/key-join-recursive-revalidation-lock.out b/src/test/isolation/expected/key-join-recursive-revalidation-lock.out
new file mode 100644
index 00000000000..4afe8c56022
--- /dev/null
+++ b/src/test/isolation/expected/key-join-recursive-revalidation-lock.out
@@ -0,0 +1,26 @@
+Parsed test spec with 3 sessions
+
+starting permutation: s3_lock_child s1_disable_rls s2_replace_dep s3_commit
+step s3_lock_child: 
+ BEGIN;
+ LOCK TABLE key_join_recursive_revalidation_lock.c
+     IN ACCESS EXCLUSIVE MODE;
+
+step s1_disable_rls: 
+ SET deadlock_timeout = '10ms';
+ ALTER TABLE key_join_recursive_revalidation_lock.x
+     DISABLE ROW LEVEL SECURITY;
+
+step s2_replace_dep: 
+ SET deadlock_timeout = '10s';
+ BEGIN;
+ ALTER VIEW key_join_recursive_revalidation_lock.dep
+     SET (security_barrier=false);
+ CREATE OR REPLACE VIEW key_join_recursive_revalidation_lock.dep AS
+ SELECT p.region, p.id,
+        (SELECT count(*) FROM key_join_recursive_revalidation_lock.x) AS marker
+ FROM key_join_recursive_revalidation_lock.p;
+ COMMIT;
+ <waiting ...>
+step s3_commit: COMMIT;
+step s2_replace_dep: <... completed>
diff --git a/src/test/isolation/expected/key-join-sqlbody-function-prelock.out b/src/test/isolation/expected/key-join-sqlbody-function-prelock.out
new file mode 100644
index 00000000000..36bfcfdefb3
--- /dev/null
+++ b/src/test/isolation/expected/key-join-sqlbody-function-prelock.out
@@ -0,0 +1,8 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_alter_f s2_alter_g s1_commit
+step s1_begin: BEGIN;
+step s1_alter_f: ALTER FUNCTION key_join_sqlbody_function_prelock.f() COST 11;
+step s2_alter_g: ALTER FUNCTION key_join_sqlbody_function_prelock.g() COST 12; <waiting ...>
+step s1_commit: COMMIT;
+step s2_alter_g: <... completed>
diff --git a/src/test/isolation/expected/key-join-stale-policy-dependency.out b/src/test/isolation/expected/key-join-stale-policy-dependency.out
new file mode 100644
index 00000000000..1a2ffe9e1af
--- /dev/null
+++ b/src/test/isolation/expected/key-join-stale-policy-dependency.out
@@ -0,0 +1,18 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_lock_policy s2_alter_fk s1_drop_policy
+step s1_lock_policy: 
+ BEGIN;
+ SET deadlock_timeout = '100ms';
+ LOCK TABLE pg_policy IN ACCESS EXCLUSIVE MODE;
+
+step s2_alter_fk: 
+ SET deadlock_timeout = '10s';
+ ALTER TABLE key_join_stale_policy_dependency.c
+     ALTER CONSTRAINT c_parent_id_fkey NOT ENFORCED;
+ <waiting ...>
+step s1_drop_policy: 
+ DROP POLICY pol ON key_join_stale_policy_dependency.guarded;
+ COMMIT;
+
+step s2_alter_fk: <... completed>
diff --git a/src/test/isolation/expected/key-join-stale-rule-dependency.out b/src/test/isolation/expected/key-join-stale-rule-dependency.out
new file mode 100644
index 00000000000..a9f74a955d8
--- /dev/null
+++ b/src/test/isolation/expected/key-join-stale-rule-dependency.out
@@ -0,0 +1,18 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_lock_rewrite s2_alter_fk s1_drop_consumer
+step s1_lock_rewrite: 
+ BEGIN;
+ SET deadlock_timeout = '100ms';
+ LOCK TABLE pg_rewrite IN ACCESS EXCLUSIVE MODE;
+
+step s2_alter_fk: 
+ SET deadlock_timeout = '10s';
+ ALTER TABLE key_join_stale_rule_dependency.c
+     ALTER CONSTRAINT c_parent_id_fkey NOT ENFORCED;
+ <waiting ...>
+step s1_drop_consumer: 
+ DROP VIEW key_join_stale_rule_dependency.consumer;
+ COMMIT;
+
+step s2_alter_fk: <... completed>
diff --git a/src/test/isolation/expected/key-join-stale-table-rule-dependency.out b/src/test/isolation/expected/key-join-stale-table-rule-dependency.out
new file mode 100644
index 00000000000..378b9f74208
--- /dev/null
+++ b/src/test/isolation/expected/key-join-stale-table-rule-dependency.out
@@ -0,0 +1,17 @@
+Parsed test spec with 2 sessions
+
+starting permutation: ddl_begin lock_rule_target alter_fk drop_rule ddl_commit
+step ddl_begin: BEGIN;
+step lock_rule_target: 
+ LOCK TABLE key_join_stale_table_rule_dependency.t
+ IN ACCESS EXCLUSIVE MODE;
+
+step alter_fk: 
+ ALTER TABLE key_join_stale_table_rule_dependency.c
+ ALTER CONSTRAINT c_parent_id_fkey NOT ENFORCED;
+ <waiting ...>
+step drop_rule: 
+ DROP RULE r ON key_join_stale_table_rule_dependency.t;
+
+step ddl_commit: COMMIT;
+step alter_fk: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index 15c33fad4c5..146bb641228 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -118,6 +118,20 @@ test: partition-split
 test: plpgsql-toast
 test: cluster-conflict
 test: cluster-conflict-partition
+test: key-join-function-race
+test: key-join-plain-function-prelock
+test: key-join-plain-function-dependent-lock
+test: key-join-plain-function-ownercheck-lock
+test: key-join-plain-function-alter-owner-lock
+test: key-join-sqlbody-function-prelock
+test: key-join-policy-function-deadlock
+test: key-join-recursive-revalidation-lock
+test: key-join-lazy-facts-lock
+test: key-join-plain-view-rls-lock
+test: key-join-plain-relation-dependent-lock
+test: key-join-stale-rule-dependency
+test: key-join-stale-table-rule-dependency
+test: key-join-stale-policy-dependency
 test: cluster-toast-value-reuse
 test: truncate-conflict
 test: serializable-parallel
diff --git a/src/test/isolation/specs/key-join-function-race.spec b/src/test/isolation/specs/key-join-function-race.spec
new file mode 100644
index 00000000000..8e9288ac8cd
--- /dev/null
+++ b/src/test/isolation/specs/key-join-function-race.spec
@@ -0,0 +1,78 @@
+# ALTER FUNCTION must serialize with key-join proof capture.
+#
+# The consumer view proves its key join from matched filters in v_p and
+# v_c.  If ALTER FUNCTION changes f() to VOLATILE while CREATE VIEW is
+# proving those filters, CREATE VIEW must either finish first and be seen
+# by the ALTER FUNCTION revalidation pass, or wait for the function lock and
+# reject the proof after observing the changed function.
+
+setup
+{
+ CREATE SCHEMA key_join_function_race;
+ CREATE TABLE key_join_function_race.p
+ (
+     region int NOT NULL,
+     id int NOT NULL,
+     PRIMARY KEY (region, id)
+ );
+ CREATE TABLE key_join_function_race.c
+ (
+     cid int PRIMARY KEY,
+     region int NOT NULL,
+     parent_id int NOT NULL,
+     FOREIGN KEY (region, parent_id)
+         REFERENCES key_join_function_race.p (region, id)
+ );
+ INSERT INTO key_join_function_race.p VALUES (1, 1), (2, 1);
+ INSERT INTO key_join_function_race.c VALUES (10, 1, 1), (20, 2, 1);
+ CREATE FUNCTION key_join_function_race.f() RETURNS int
+     LANGUAGE sql STABLE AS 'SELECT 1';
+ CREATE FUNCTION key_join_function_race.g() RETURNS int
+     LANGUAGE sql STABLE AS 'SELECT 1';
+ CREATE FUNCTION key_join_function_race.h() RETURNS int
+     LANGUAGE sql STABLE AS 'SELECT 1';
+ CREATE VIEW key_join_function_race.v_p AS
+     SELECT * FROM key_join_function_race.p
+     WHERE region = key_join_function_race.f();
+ CREATE VIEW key_join_function_race.v_c AS
+     SELECT * FROM key_join_function_race.c
+     WHERE region = key_join_function_race.f();
+}
+
+teardown
+{
+ DROP SCHEMA key_join_function_race CASCADE;
+}
+
+session s1
+step s1_begin { BEGIN; }
+step s1_alter
+{
+ CREATE OR REPLACE FUNCTION key_join_function_race.f() RETURNS int
+     LANGUAGE sql VOLATILE AS 'SELECT 1';
+}
+step s1_lock_g { ALTER FUNCTION key_join_function_race.g() COST 1; }
+step s1_drop_g { DROP FUNCTION key_join_function_race.g(); }
+step s1_lock_h { ALTER FUNCTION key_join_function_race.h() COST 1; }
+step s1_drop_h { DROP FUNCTION key_join_function_race.h(); }
+step s1_commit { COMMIT; }
+
+session s2
+step s2_create_view
+{
+ CREATE VIEW key_join_function_race.v AS
+ SELECT v_c.cid, v_p.id
+ FROM key_join_function_race.v_p
+ JOIN key_join_function_race.v_c
+     FOR KEY (region, parent_id) -> v_p (region, id);
+}
+step s2_replace_g
+{
+ CREATE OR REPLACE FUNCTION key_join_function_race.g() RETURNS int
+     LANGUAGE sql STABLE AS 'SELECT 2';
+}
+step s2_alter_h { ALTER FUNCTION key_join_function_race.h() COST 2; }
+
+permutation s1_begin s1_alter s2_create_view s1_commit
+permutation s1_begin s1_lock_g s2_replace_g s1_drop_g s1_commit
+permutation s1_begin s1_lock_h s2_alter_h s1_drop_h s1_commit
diff --git a/src/test/isolation/specs/key-join-lazy-facts-lock.spec b/src/test/isolation/specs/key-join-lazy-facts-lock.spec
new file mode 100644
index 00000000000..094b07fc979
--- /dev/null
+++ b/src/test/isolation/specs/key-join-lazy-facts-lock.spec
@@ -0,0 +1,34 @@
+# Plain non-key-join queries must not compute key-join FK facts.
+#
+# Before key-join facts became demand-driven, SELECTing from the child table
+# tried to inspect its FK and took AccessShareLock on the referenced parent.
+
+setup
+{
+ CREATE SCHEMA key_join_lazy_facts_lock;
+ CREATE TABLE key_join_lazy_facts_lock.p
+ (
+     id int PRIMARY KEY
+ );
+ CREATE TABLE key_join_lazy_facts_lock.c
+ (
+     id int PRIMARY KEY,
+     parent_id int NOT NULL REFERENCES key_join_lazy_facts_lock.p (id)
+ );
+ INSERT INTO key_join_lazy_facts_lock.p VALUES (1);
+ INSERT INTO key_join_lazy_facts_lock.c VALUES (10, 1);
+}
+
+teardown
+{
+ DROP SCHEMA key_join_lazy_facts_lock CASCADE;
+}
+
+session s1
+step s1_lock { BEGIN; LOCK TABLE key_join_lazy_facts_lock.p IN ACCESS EXCLUSIVE MODE; }
+step s1_commit { COMMIT; }
+
+session s2
+step s2_select_child { SELECT id, parent_id FROM key_join_lazy_facts_lock.c; }
+
+permutation s1_lock s2_select_child s1_commit
diff --git a/src/test/isolation/specs/key-join-plain-function-alter-owner-lock.spec b/src/test/isolation/specs/key-join-plain-function-alter-owner-lock.spec
new file mode 100644
index 00000000000..4c3e30a8981
--- /dev/null
+++ b/src/test/isolation/specs/key-join-plain-function-alter-owner-lock.spec
@@ -0,0 +1,64 @@
+# Unauthorized ALTER FUNCTION must not wait on key-join proof serialization.
+#
+# Session s1 alters f() and keeps the transaction open.  Session s2 lacks
+# ownership of f(), so ALTER FUNCTION should fail the owner check immediately.
+# It should not wait on s1's key-join proof serialization lock.
+
+setup
+{
+ CREATE ROLE key_join_plain_function_alter_owner;
+ CREATE ROLE key_join_plain_function_alter_other;
+ CREATE SCHEMA key_join_plain_function_alter_owner_lock
+     AUTHORIZATION key_join_plain_function_alter_owner;
+ GRANT USAGE ON SCHEMA key_join_plain_function_alter_owner_lock
+     TO key_join_plain_function_alter_other;
+ SET ROLE key_join_plain_function_alter_owner;
+ CREATE FUNCTION key_join_plain_function_alter_owner_lock.f() RETURNS int
+     LANGUAGE sql RETURN 1;
+ CREATE FUNCTION key_join_plain_function_alter_owner_lock.g() RETURNS int
+     LANGUAGE sql RETURN 1;
+ RESET ROLE;
+}
+
+teardown
+{
+ DROP SCHEMA key_join_plain_function_alter_owner_lock CASCADE;
+ DROP ROLE key_join_plain_function_alter_owner;
+ DROP ROLE key_join_plain_function_alter_other;
+}
+
+session s1
+step s1_begin { BEGIN; }
+step s1_alter_f
+{
+ ALTER FUNCTION key_join_plain_function_alter_owner_lock.f() COST 11;
+}
+step s1_alter_g
+{
+ ALTER FUNCTION key_join_plain_function_alter_owner_lock.g() COST 21;
+}
+step s1_owner_g
+{
+ ALTER FUNCTION key_join_plain_function_alter_owner_lock.g()
+     OWNER TO key_join_plain_function_alter_other;
+}
+step s1_commit { COMMIT; }
+
+session s2
+step s2_auth { SET ROLE key_join_plain_function_alter_other; }
+step s2_auth_owner
+{
+ RESET ROLE;
+ SET ROLE key_join_plain_function_alter_owner;
+}
+step s2_alter_f
+{
+ ALTER FUNCTION key_join_plain_function_alter_owner_lock.f() COST 12;
+}
+step s2_alter_g
+{
+ ALTER FUNCTION key_join_plain_function_alter_owner_lock.g() COST 22;
+}
+
+permutation s1_begin s1_alter_f s2_auth s2_alter_f s1_commit
+permutation s1_begin s1_alter_g s2_auth_owner s2_alter_g s1_owner_g s1_commit
diff --git a/src/test/isolation/specs/key-join-plain-function-dependent-lock.spec b/src/test/isolation/specs/key-join-plain-function-dependent-lock.spec
new file mode 100644
index 00000000000..38aaf7b379c
--- /dev/null
+++ b/src/test/isolation/specs/key-join-plain-function-dependent-lock.spec
@@ -0,0 +1,30 @@
+# Ordinary SQL function DDL must not hold locks on ordinary dependent
+# functions due to key-join proof prelocking.
+#
+# f() has a normal SQL-body dependency on g().  Session s1 alters g() and
+# keeps the transaction open.  Altering f() in session s2 should not wait on
+# s1, since f() contains no stored FOR KEY proof.
+
+setup
+{
+ CREATE SCHEMA key_join_plain_function_dependent_lock;
+ CREATE FUNCTION key_join_plain_function_dependent_lock.g() RETURNS int
+     LANGUAGE sql RETURN 1;
+ CREATE FUNCTION key_join_plain_function_dependent_lock.f() RETURNS int
+     LANGUAGE sql RETURN key_join_plain_function_dependent_lock.g();
+}
+
+teardown
+{
+ DROP SCHEMA key_join_plain_function_dependent_lock CASCADE;
+}
+
+session s1
+step s1_begin { BEGIN; }
+step s1_alter_g { ALTER FUNCTION key_join_plain_function_dependent_lock.g() COST 11; }
+step s1_commit { COMMIT; }
+
+session s2
+step s2_alter_f { ALTER FUNCTION key_join_plain_function_dependent_lock.f() COST 12; }
+
+permutation s1_begin s1_alter_g s2_alter_f s1_commit
diff --git a/src/test/isolation/specs/key-join-plain-function-ownercheck-lock.spec b/src/test/isolation/specs/key-join-plain-function-ownercheck-lock.spec
new file mode 100644
index 00000000000..5f5704b584a
--- /dev/null
+++ b/src/test/isolation/specs/key-join-plain-function-ownercheck-lock.spec
@@ -0,0 +1,54 @@
+# Unauthorized CREATE OR REPLACE FUNCTION must not wait on key-join proof
+# serialization.
+#
+# Session s1 alters f() and keeps the transaction open.  Session s2 lacks
+# ownership of f(), so CREATE OR REPLACE FUNCTION should fail the owner check
+# immediately.  It should not wait on s1's key-join proof serialization lock.
+
+setup
+{
+ CREATE ROLE key_join_plain_function_owner;
+ CREATE ROLE key_join_plain_function_other;
+ CREATE SCHEMA key_join_plain_function_ownercheck_lock
+     AUTHORIZATION key_join_plain_function_owner;
+ GRANT USAGE, CREATE ON SCHEMA key_join_plain_function_ownercheck_lock
+     TO key_join_plain_function_other;
+ SET ROLE key_join_plain_function_owner;
+ CREATE FUNCTION key_join_plain_function_ownercheck_lock.f() RETURNS int
+     LANGUAGE sql RETURN 1;
+ RESET ROLE;
+}
+
+teardown
+{
+ DROP SCHEMA key_join_plain_function_ownercheck_lock CASCADE;
+ DROP ROLE key_join_plain_function_owner;
+ DROP ROLE key_join_plain_function_other;
+}
+
+session s1
+step s1_begin { BEGIN; }
+step s1_alter_f
+{
+ ALTER FUNCTION key_join_plain_function_ownercheck_lock.f() COST 11;
+}
+step s1_owner_f
+{
+ ALTER FUNCTION key_join_plain_function_ownercheck_lock.f()
+ OWNER TO key_join_plain_function_other;
+}
+step s1_commit { COMMIT; }
+
+session s2
+step s2_auth { SET ROLE key_join_plain_function_other; }
+step s2_auth_owner { SET ROLE key_join_plain_function_owner; }
+step s2_replace_f
+{
+ CREATE OR REPLACE FUNCTION key_join_plain_function_ownercheck_lock.f()
+ RETURNS int
+ LANGUAGE sql
+ RETURN 2;
+}
+
+permutation s1_begin s1_alter_f s2_auth s2_replace_f s1_commit
+permutation s1_begin s1_alter_f s2_auth_owner s2_replace_f s1_owner_f s1_commit
diff --git a/src/test/isolation/specs/key-join-plain-function-prelock.spec b/src/test/isolation/specs/key-join-plain-function-prelock.spec
new file mode 100644
index 00000000000..cba0b0da4b9
--- /dev/null
+++ b/src/test/isolation/specs/key-join-plain-function-prelock.spec
@@ -0,0 +1,30 @@
+# Ordinary SQL function DDL must not wait on function dependents due to
+# key-join proof prelocking.
+#
+# f() has a normal SQL-body dependency on g().  Session s1 holds an ALTER
+# FUNCTION lock on f().  Altering g() in session s2 should not lock f(), since
+# no stored FOR KEY proof is involved in this workload.
+
+setup
+{
+ CREATE SCHEMA key_join_plain_function_prelock;
+ CREATE FUNCTION key_join_plain_function_prelock.g() RETURNS int
+     LANGUAGE sql RETURN 1;
+ CREATE FUNCTION key_join_plain_function_prelock.f() RETURNS int
+     LANGUAGE sql RETURN key_join_plain_function_prelock.g();
+}
+
+teardown
+{
+ DROP SCHEMA key_join_plain_function_prelock CASCADE;
+}
+
+session s1
+step s1_begin { BEGIN; }
+step s1_alter_f { ALTER FUNCTION key_join_plain_function_prelock.f() COST 11; }
+step s1_commit { COMMIT; }
+
+session s2
+step s2_alter_g { ALTER FUNCTION key_join_plain_function_prelock.g() COST 12; }
+
+permutation s1_begin s1_alter_f s2_alter_g s1_commit
diff --git a/src/test/isolation/specs/key-join-plain-relation-dependent-lock.spec b/src/test/isolation/specs/key-join-plain-relation-dependent-lock.spec
new file mode 100644
index 00000000000..81e7a4c9aa7
--- /dev/null
+++ b/src/test/isolation/specs/key-join-plain-relation-dependent-lock.spec
@@ -0,0 +1,42 @@
+# Ordinary relation DDL must not hold locks on ordinary dependent views due
+# to key-join proof prelocking.
+#
+# v has a normal view dependency on t and contains no stored FOR KEY proof.
+# Session s1 changes t in a way that triggers key-join dependent prelocking
+# and keeps the transaction open.  Altering v in session s2 should not wait
+# on s1.
+
+setup
+{
+ CREATE SCHEMA key_join_plain_relation_dependent_lock;
+ CREATE TABLE key_join_plain_relation_dependent_lock.t
+ (
+     id int PRIMARY KEY
+ );
+ CREATE VIEW key_join_plain_relation_dependent_lock.v AS
+ SELECT id
+ FROM key_join_plain_relation_dependent_lock.t;
+}
+
+teardown
+{
+ DROP SCHEMA key_join_plain_relation_dependent_lock CASCADE;
+}
+
+session s1
+step s1_begin { BEGIN; }
+step s1_enable_rls
+{
+ ALTER TABLE key_join_plain_relation_dependent_lock.t
+     ENABLE ROW LEVEL SECURITY;
+}
+step s1_commit { COMMIT; }
+
+session s2
+step s2_alter_view
+{
+ ALTER VIEW key_join_plain_relation_dependent_lock.v
+     SET (security_barrier=true);
+}
+
+permutation s1_begin s1_enable_rls s2_alter_view s1_commit
diff --git a/src/test/isolation/specs/key-join-plain-view-rls-lock.spec b/src/test/isolation/specs/key-join-plain-view-rls-lock.spec
new file mode 100644
index 00000000000..31a9825b1c2
--- /dev/null
+++ b/src/test/isolation/specs/key-join-plain-view-rls-lock.spec
@@ -0,0 +1,41 @@
+# Ordinary dependent views must not be locked during key-join revalidation.
+#
+# Enabling RLS on a base table takes AccessExclusiveLock on the base table.
+# It should not also wait for a lock on an ordinary dependent view that does
+# not contain a FOR KEY join.  The view DDL below holds a lock on the view
+# only; ALTER TABLE should still complete without waiting for that lock.
+
+setup
+{
+ CREATE SCHEMA key_join_plain_view_rls_lock;
+ CREATE TABLE key_join_plain_view_rls_lock.t
+ (
+     id int PRIMARY KEY
+ );
+ CREATE VIEW key_join_plain_view_rls_lock.v AS
+ SELECT id
+ FROM key_join_plain_view_rls_lock.t;
+}
+
+teardown
+{
+ DROP SCHEMA key_join_plain_view_rls_lock CASCADE;
+}
+
+session s1
+step s1_lock_view
+{
+ BEGIN;
+ ALTER VIEW key_join_plain_view_rls_lock.v
+     SET (security_barrier=true);
+}
+step s1_commit { COMMIT; }
+
+session s2
+step s2_enable_rls
+{
+ ALTER TABLE key_join_plain_view_rls_lock.t
+     ENABLE ROW LEVEL SECURITY;
+}
+
+permutation s1_lock_view s2_enable_rls s1_commit
diff --git a/src/test/isolation/specs/key-join-policy-function-deadlock.spec b/src/test/isolation/specs/key-join-policy-function-deadlock.spec
new file mode 100644
index 00000000000..07876a37f95
--- /dev/null
+++ b/src/test/isolation/specs/key-join-policy-function-deadlock.spec
@@ -0,0 +1,68 @@
+# ALTER FUNCTION must not deadlock with ALTER POLICY revalidation.
+#
+# The original policy depends on f() as an ordinary RLS expression, not as a
+# FOR KEY proof dependency.  ALTER FUNCTION used to lock f(), then revalidate
+# the dependent policy by taking AccessExclusiveLock on the guarded table.
+# That inverted ALTER POLICY's table-then-proof-function lock order.
+
+setup
+{
+ CREATE SCHEMA key_join_policy_function_deadlock;
+ CREATE TABLE key_join_policy_function_deadlock.p
+ (
+     region int NOT NULL,
+     id int NOT NULL,
+     PRIMARY KEY (region, id)
+ );
+ CREATE TABLE key_join_policy_function_deadlock.c
+ (
+     cid int PRIMARY KEY,
+     region int NOT NULL,
+     parent_id int NOT NULL,
+     FOREIGN KEY (region, parent_id)
+         REFERENCES key_join_policy_function_deadlock.p (region, id)
+ );
+ CREATE TABLE key_join_policy_function_deadlock.guarded (id int);
+ INSERT INTO key_join_policy_function_deadlock.p VALUES (1, 1), (2, 1);
+ INSERT INTO key_join_policy_function_deadlock.c VALUES (10, 1, 1), (20, 2, 1);
+ CREATE FUNCTION key_join_policy_function_deadlock.f() RETURNS int
+     LANGUAGE sql STABLE AS 'SELECT 1';
+ ALTER TABLE key_join_policy_function_deadlock.guarded ENABLE ROW LEVEL SECURITY;
+ CREATE POLICY pol ON key_join_policy_function_deadlock.guarded
+     USING (key_join_policy_function_deadlock.f() = 1);
+}
+
+teardown
+{
+ DROP SCHEMA key_join_policy_function_deadlock CASCADE;
+}
+
+session s1
+step s1_lock_table
+{
+ BEGIN;
+ SET deadlock_timeout = '10ms';
+ LOCK TABLE key_join_policy_function_deadlock.guarded IN ACCESS EXCLUSIVE MODE;
+}
+step s1_alter_policy
+{
+ ALTER POLICY pol ON key_join_policy_function_deadlock.guarded
+ USING (EXISTS (
+     SELECT 1
+     FROM (SELECT * FROM key_join_policy_function_deadlock.p
+           WHERE region = key_join_policy_function_deadlock.f()) p
+     JOIN (SELECT * FROM key_join_policy_function_deadlock.c
+           WHERE region = key_join_policy_function_deadlock.f()) c
+         FOR KEY (region, parent_id) -> p (region, id)
+ ));
+}
+step s1_rollback { ROLLBACK; }
+
+session s2
+step s2_alter_function
+{
+ SET deadlock_timeout = '10s';
+ ALTER FUNCTION key_join_policy_function_deadlock.f() VOLATILE;
+}
+
+permutation s1_lock_table s2_alter_function s1_alter_policy s1_rollback
diff --git a/src/test/isolation/specs/key-join-recursive-revalidation-lock.spec b/src/test/isolation/specs/key-join-recursive-revalidation-lock.spec
new file mode 100644
index 00000000000..cf4ab83d85a
--- /dev/null
+++ b/src/test/isolation/specs/key-join-recursive-revalidation-lock.spec
@@ -0,0 +1,79 @@
+# Key-join revalidation must not use ordinary dependencies as traversal
+# bridges.
+#
+# Disabling RLS on x changes an ordinary dependency of dep but not a proof
+# surface consumed by consumer, so it must not wait on consumer's locked child
+# table.  Replacing dep does change the consumed proof surface and must still
+# wait on consumer without deadlocking.
+
+setup
+{
+ CREATE SCHEMA key_join_recursive_revalidation_lock;
+ CREATE TABLE key_join_recursive_revalidation_lock.x (id int);
+ ALTER TABLE key_join_recursive_revalidation_lock.x ENABLE ROW LEVEL SECURITY;
+ CREATE TABLE key_join_recursive_revalidation_lock.p
+ (
+     region int NOT NULL,
+     id int NOT NULL,
+     PRIMARY KEY (region, id)
+ );
+ CREATE TABLE key_join_recursive_revalidation_lock.c
+ (
+     cid int PRIMARY KEY,
+     region int NOT NULL,
+     parent_id int NOT NULL,
+     FOREIGN KEY (region, parent_id)
+         REFERENCES key_join_recursive_revalidation_lock.p (region, id)
+ );
+ INSERT INTO key_join_recursive_revalidation_lock.x VALUES (1);
+ INSERT INTO key_join_recursive_revalidation_lock.p VALUES (1, 1), (2, 1);
+ INSERT INTO key_join_recursive_revalidation_lock.c
+     VALUES (10, 1, 1), (20, 2, 1);
+ CREATE VIEW key_join_recursive_revalidation_lock.dep AS
+ SELECT p.region, p.id,
+        (SELECT count(*) FROM key_join_recursive_revalidation_lock.x) AS marker
+ FROM key_join_recursive_revalidation_lock.p;
+ CREATE VIEW key_join_recursive_revalidation_lock.consumer AS
+ SELECT c.cid, dep.id
+ FROM key_join_recursive_revalidation_lock.dep
+ JOIN key_join_recursive_revalidation_lock.c
+     FOR KEY (region, parent_id) -> dep (region, id);
+}
+
+teardown
+{
+ DROP SCHEMA key_join_recursive_revalidation_lock CASCADE;
+}
+
+session s1
+step s1_disable_rls
+{
+ SET deadlock_timeout = '10ms';
+ ALTER TABLE key_join_recursive_revalidation_lock.x
+     DISABLE ROW LEVEL SECURITY;
+}
+
+session s2
+step s2_replace_dep
+{
+ SET deadlock_timeout = '10s';
+ BEGIN;
+ ALTER VIEW key_join_recursive_revalidation_lock.dep
+     SET (security_barrier=false);
+ CREATE OR REPLACE VIEW key_join_recursive_revalidation_lock.dep AS
+ SELECT p.region, p.id,
+        (SELECT count(*) FROM key_join_recursive_revalidation_lock.x) AS marker
+ FROM key_join_recursive_revalidation_lock.p;
+ COMMIT;
+}
+
+session s3
+step s3_lock_child
+{
+ BEGIN;
+ LOCK TABLE key_join_recursive_revalidation_lock.c
+     IN ACCESS EXCLUSIVE MODE;
+}
+step s3_commit { COMMIT; }
+
+permutation s3_lock_child s1_disable_rls s2_replace_dep s3_commit
diff --git a/src/test/isolation/specs/key-join-sqlbody-function-prelock.spec b/src/test/isolation/specs/key-join-sqlbody-function-prelock.spec
new file mode 100644
index 00000000000..d563f3198cc
--- /dev/null
+++ b/src/test/isolation/specs/key-join-sqlbody-function-prelock.spec
@@ -0,0 +1,55 @@
+# SQL functions with stored key-join proofs must still serialize with
+# ALTER FUNCTION on their proof dependencies.
+#
+# f() has a stored SQL body containing a FOR KEY proof and depends on g().
+# Session s1 holds an ALTER FUNCTION lock on f().  Altering g() in session s2
+# must wait for f(), because the stored proof in f() needs revalidation.
+
+setup
+{
+ CREATE SCHEMA key_join_sqlbody_function_prelock;
+ CREATE TABLE key_join_sqlbody_function_prelock.p
+ (
+     region int NOT NULL,
+     id int NOT NULL,
+     PRIMARY KEY (region, id)
+ );
+ CREATE TABLE key_join_sqlbody_function_prelock.c
+ (
+     cid int PRIMARY KEY,
+     region int NOT NULL,
+     parent_id int NOT NULL,
+     FOREIGN KEY (region, parent_id)
+         REFERENCES key_join_sqlbody_function_prelock.p (region, id)
+ );
+ INSERT INTO key_join_sqlbody_function_prelock.p VALUES (1, 1), (2, 1);
+ INSERT INTO key_join_sqlbody_function_prelock.c VALUES (10, 1, 1), (20, 2, 1);
+ CREATE FUNCTION key_join_sqlbody_function_prelock.g() RETURNS int
+     LANGUAGE sql STABLE RETURN 1;
+ CREATE FUNCTION key_join_sqlbody_function_prelock.f()
+ RETURNS TABLE(cid int)
+ LANGUAGE sql
+ BEGIN ATOMIC
+     SELECT c.cid
+     FROM (SELECT * FROM key_join_sqlbody_function_prelock.p
+           WHERE region = key_join_sqlbody_function_prelock.g()) p
+     JOIN (SELECT * FROM key_join_sqlbody_function_prelock.c
+           WHERE region = key_join_sqlbody_function_prelock.g()) c
+         FOR KEY (region, parent_id) -> p (region, id);
+ END;
+}
+
+teardown
+{
+ DROP SCHEMA key_join_sqlbody_function_prelock CASCADE;
+}
+
+session s1
+step s1_begin { BEGIN; }
+step s1_alter_f { ALTER FUNCTION key_join_sqlbody_function_prelock.f() COST 11; }
+step s1_commit { COMMIT; }
+
+session s2
+step s2_alter_g { ALTER FUNCTION key_join_sqlbody_function_prelock.g() COST 12; }
+
+permutation s1_begin s1_alter_f s2_alter_g s1_commit
diff --git a/src/test/isolation/specs/key-join-stale-policy-dependency.spec b/src/test/isolation/specs/key-join-stale-policy-dependency.spec
new file mode 100644
index 00000000000..a3689b06f0c
--- /dev/null
+++ b/src/test/isolation/specs/key-join-stale-policy-dependency.spec
@@ -0,0 +1,65 @@
+# Key-join revalidation must tolerate a concurrently dropped dependent policy.
+#
+# Revalidation may find an RLS policy in pg_depend, then wait before reading
+# pg_policy.  If the policy is dropped while it waits, the stored proof is gone
+# and the triggering DDL should not dereference the stale policy OID.
+
+setup
+{
+ CREATE SCHEMA key_join_stale_policy_dependency;
+ CREATE TABLE key_join_stale_policy_dependency.p
+ (
+     id int PRIMARY KEY
+ );
+ CREATE TABLE key_join_stale_policy_dependency.c
+ (
+     id int PRIMARY KEY,
+     parent_id int CONSTRAINT c_parent_id_not_null NOT NULL,
+     CONSTRAINT c_parent_id_fkey
+         FOREIGN KEY (parent_id)
+         REFERENCES key_join_stale_policy_dependency.p (id)
+ );
+ CREATE TABLE key_join_stale_policy_dependency.guarded
+ (
+     id int
+ );
+ INSERT INTO key_join_stale_policy_dependency.p VALUES (1);
+ INSERT INTO key_join_stale_policy_dependency.c VALUES (10, 1);
+ ALTER TABLE key_join_stale_policy_dependency.guarded
+     ENABLE ROW LEVEL SECURITY;
+ CREATE POLICY pol ON key_join_stale_policy_dependency.guarded
+ USING (EXISTS (
+     SELECT 1
+     FROM key_join_stale_policy_dependency.p p
+     JOIN key_join_stale_policy_dependency.c c
+         FOR KEY (parent_id) -> p (id)
+ ));
+}
+
+teardown
+{
+ DROP SCHEMA key_join_stale_policy_dependency CASCADE;
+}
+
+session s1
+step s1_lock_policy
+{
+ BEGIN;
+ SET deadlock_timeout = '100ms';
+ LOCK TABLE pg_policy IN ACCESS EXCLUSIVE MODE;
+}
+step s1_drop_policy
+{
+ DROP POLICY pol ON key_join_stale_policy_dependency.guarded;
+ COMMIT;
+}
+
+session s2
+step s2_alter_fk
+{
+ SET deadlock_timeout = '10s';
+ ALTER TABLE key_join_stale_policy_dependency.c
+     ALTER CONSTRAINT c_parent_id_fkey NOT ENFORCED;
+}
+
+permutation s1_lock_policy s2_alter_fk s1_drop_policy
diff --git a/src/test/isolation/specs/key-join-stale-rule-dependency.spec b/src/test/isolation/specs/key-join-stale-rule-dependency.spec
new file mode 100644
index 00000000000..18c85e5400c
--- /dev/null
+++ b/src/test/isolation/specs/key-join-stale-rule-dependency.spec
@@ -0,0 +1,58 @@
+# Key-join revalidation must tolerate a concurrently dropped dependent rule.
+#
+# Revalidation scans pg_depend for stored key-join proof dependencies, then
+# resolves a dependent view's rewrite rule to find the relation to revalidate.
+# If that view is dropped after the dependency scan but before the rule lookup,
+# the revalidation target is gone and the triggering DDL should continue.
+
+setup
+{
+ CREATE SCHEMA key_join_stale_rule_dependency;
+ CREATE TABLE key_join_stale_rule_dependency.p
+ (
+     id int PRIMARY KEY
+ );
+ CREATE TABLE key_join_stale_rule_dependency.c
+ (
+     id int PRIMARY KEY,
+     parent_id int CONSTRAINT c_parent_id_not_null NOT NULL,
+     CONSTRAINT c_parent_id_fkey
+         FOREIGN KEY (parent_id)
+         REFERENCES key_join_stale_rule_dependency.p (id)
+ );
+ INSERT INTO key_join_stale_rule_dependency.p VALUES (1);
+ INSERT INTO key_join_stale_rule_dependency.c VALUES (10, 1);
+ CREATE VIEW key_join_stale_rule_dependency.consumer AS
+ SELECT p.id AS parent_id, c.id AS child_id
+ FROM key_join_stale_rule_dependency.p p
+ JOIN key_join_stale_rule_dependency.c c
+     FOR KEY (parent_id) -> p (id);
+}
+
+teardown
+{
+ DROP SCHEMA key_join_stale_rule_dependency CASCADE;
+}
+
+session s1
+step s1_lock_rewrite
+{
+ BEGIN;
+ SET deadlock_timeout = '100ms';
+ LOCK TABLE pg_rewrite IN ACCESS EXCLUSIVE MODE;
+}
+step s1_drop_consumer
+{
+ DROP VIEW key_join_stale_rule_dependency.consumer;
+ COMMIT;
+}
+
+session s2
+step s2_alter_fk
+{
+ SET deadlock_timeout = '10s';
+ ALTER TABLE key_join_stale_rule_dependency.c
+     ALTER CONSTRAINT c_parent_id_fkey NOT ENFORCED;
+}
+
+permutation s1_lock_rewrite s2_alter_fk s1_drop_consumer
diff --git a/src/test/isolation/specs/key-join-stale-table-rule-dependency.spec b/src/test/isolation/specs/key-join-stale-table-rule-dependency.spec
new file mode 100644
index 00000000000..7984b148be2
--- /dev/null
+++ b/src/test/isolation/specs/key-join-stale-table-rule-dependency.spec
@@ -0,0 +1,61 @@
+# Key-join revalidation races against a dropped table rewrite rule.
+#
+# pg_depend points from a stored key-join proof to the rewrite rule that owns
+# it.  If that rule is dropped after dependency scanning but before the owning
+# relation can be opened, the relation may still exist with no rules left.
+
+setup
+{
+ CREATE SCHEMA key_join_stale_table_rule_dependency;
+ CREATE TABLE key_join_stale_table_rule_dependency.p
+ (
+     id integer PRIMARY KEY
+ );
+ CREATE TABLE key_join_stale_table_rule_dependency.c
+ (
+     id integer PRIMARY KEY,
+     parent_id integer CONSTRAINT c_parent_id_not_null NOT NULL,
+     CONSTRAINT c_parent_id_fkey
+         FOREIGN KEY (parent_id)
+         REFERENCES key_join_stale_table_rule_dependency.p (id)
+ );
+ CREATE TABLE key_join_stale_table_rule_dependency.t
+ (
+     id integer
+ );
+ INSERT INTO key_join_stale_table_rule_dependency.p VALUES (1);
+ INSERT INTO key_join_stale_table_rule_dependency.c VALUES (10, 1);
+ CREATE RULE r AS ON INSERT TO key_join_stale_table_rule_dependency.t
+ DO ALSO
+     SELECT p.id AS pid, c.id AS cid
+     FROM key_join_stale_table_rule_dependency.p p
+     JOIN key_join_stale_table_rule_dependency.c c
+       FOR KEY (parent_id) -> p (id);
+}
+
+teardown
+{
+ DROP SCHEMA key_join_stale_table_rule_dependency CASCADE;
+}
+
+session ddl
+step ddl_begin { BEGIN; }
+step lock_rule_target
+{
+ LOCK TABLE key_join_stale_table_rule_dependency.t
+ IN ACCESS EXCLUSIVE MODE;
+}
+step drop_rule
+{
+ DROP RULE r ON key_join_stale_table_rule_dependency.t;
+}
+step ddl_commit { COMMIT; }
+
+session alterer
+step alter_fk
+{
+ ALTER TABLE key_join_stale_table_rule_dependency.c
+ ALTER CONSTRAINT c_parent_id_fkey NOT ENFORCED;
+}
+
+permutation ddl_begin lock_rule_target alter_fk drop_rule ddl_commit
diff --git a/src/test/modules/injection_points/expected/key_join.out b/src/test/modules/injection_points/expected/key_join.out
new file mode 100644
index 00000000000..affb20b55b0
--- /dev/null
+++ b/src/test/modules/injection_points/expected/key_join.out
@@ -0,0 +1,59 @@
+CREATE EXTENSION injection_points;
+SELECT injection_points_set_local();
+ injection_points_set_local 
+----------------------------
+ 
+(1 row)
+
+CREATE SCHEMA injection_point_key_join;
+CREATE TABLE injection_point_key_join.parent
+(
+    id int PRIMARY KEY,
+    visible text
+);
+CREATE TABLE injection_point_key_join.child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL
+        REFERENCES injection_point_key_join.parent (id),
+    visible text
+);
+INSERT INTO injection_point_key_join.parent VALUES (1, 'parent-one');
+INSERT INTO injection_point_key_join.child VALUES (10, 1, 'child-one');
+-- Populate the relcache FK list before attaching the injection point.
+SELECT p.visible AS parent_visible, c.visible AS child_visible
+FROM injection_point_key_join.parent p
+JOIN injection_point_key_join.child c FOR KEY (parent_id) -> p (id);
+ parent_visible | child_visible 
+----------------+---------------
+ parent-one     | child-one
+(1 row)
+
+SELECT injection_points_attach('key-join-after-fkey-list-copy',
+                               'invalidate_system_caches');
+ injection_points_attach 
+-------------------------
+ 
+(1 row)
+
+SELECT p.visible AS parent_visible, c.visible AS child_visible
+FROM injection_point_key_join.parent p
+JOIN injection_point_key_join.child c FOR KEY (parent_id) -> p (id);
+NOTICE:  system caches invalidated for injection point key-join-after-fkey-list-copy
+NOTICE:  system caches invalidated for injection point key-join-after-fkey-list-copy
+ parent_visible | child_visible 
+----------------+---------------
+ parent-one     | child-one
+(1 row)
+
+SELECT injection_points_detach('key-join-after-fkey-list-copy');
+ injection_points_detach 
+-------------------------
+ 
+(1 row)
+
+DROP SCHEMA injection_point_key_join CASCADE;
+NOTICE:  drop cascades to 2 other objects
+DETAIL:  drop cascades to table injection_point_key_join.parent
+drop cascades to table injection_point_key_join.child
+DROP EXTENSION injection_points;
diff --git a/src/test/modules/injection_points/expected/key_join_proof_race_constraints.out b/src/test/modules/injection_points/expected/key_join_proof_race_constraints.out
new file mode 100644
index 00000000000..e7159e62bf8
--- /dev/null
+++ b/src/test/modules/injection_points/expected/key_join_proof_race_constraints.out
@@ -0,0 +1,66 @@
+Parsed test spec with 3 sessions
+
+starting permutation: create_view drop_fk wakeup detach
+step create_view: 
+	CREATE VIEW consumer AS
+	SELECT p.id AS parent_id, c.id AS child_id,
+		   c.parent_id AS child_parent_id
+	FROM parent_v p
+	LEFT JOIN child_v c FOR KEY (parent_id) -> p (id);
+ <waiting ...>
+step drop_fk: 
+	ALTER TABLE child DROP CONSTRAINT child_parent_id_fkey;
+ <waiting ...>
+step wakeup: 
+	SELECT FROM injection_points_wakeup('key-join-after-proof-match');
+
+step create_view: <... completed>
+step drop_fk: <... completed>
+ERROR:  cannot drop constraint child_parent_id_fkey on table child because other objects depend on it
+step detach: 
+	SELECT FROM injection_points_detach('key-join-after-proof-match');
+
+
+starting permutation: create_view drop_not_null wakeup detach
+step create_view: 
+	CREATE VIEW consumer AS
+	SELECT p.id AS parent_id, c.id AS child_id,
+		   c.parent_id AS child_parent_id
+	FROM parent_v p
+	LEFT JOIN child_v c FOR KEY (parent_id) -> p (id);
+ <waiting ...>
+step drop_not_null: 
+	ALTER TABLE child ALTER COLUMN parent_id DROP NOT NULL;
+ <waiting ...>
+step wakeup: 
+	SELECT FROM injection_points_wakeup('key-join-after-proof-match');
+
+step create_view: <... completed>
+step drop_not_null: <... completed>
+ERROR:  cannot drop constraint child_parent_id_not_null on table child because other objects depend on it
+step detach: 
+	SELECT FROM injection_points_detach('key-join-after-proof-match');
+
+
+starting permutation: create_view drop_pk_cascade wakeup detach
+step create_view: 
+	CREATE VIEW consumer AS
+	SELECT p.id AS parent_id, c.id AS child_id,
+		   c.parent_id AS child_parent_id
+	FROM parent_v p
+	LEFT JOIN child_v c FOR KEY (parent_id) -> p (id);
+ <waiting ...>
+step drop_pk_cascade: 
+	ALTER TABLE parent DROP CONSTRAINT parent_pkey CASCADE;
+ <waiting ...>
+step wakeup: 
+	SELECT FROM injection_points_wakeup('key-join-after-proof-match');
+
+step create_view: <... completed>
+ddl: NOTICE:  drop cascades to 2 other objects
+DETAIL:  drop cascades to constraint child_parent_id_fkey on table child
+drop cascades to view consumer
+step drop_pk_cascade: <... completed>
+step detach: 
+	SELECT FROM injection_points_detach('key-join-after-proof-match');
+
diff --git a/src/test/modules/injection_points/expected/key_join_proof_race_filter_function_prelock.out b/src/test/modules/injection_points/expected/key_join_proof_race_filter_function_prelock.out
new file mode 100644
index 00000000000..09832fb8f46
--- /dev/null
+++ b/src/test/modules/injection_points/expected/key_join_proof_race_filter_function_prelock.out
@@ -0,0 +1,20 @@
+Parsed test spec with 3 sessions
+
+starting permutation: create_view drop_function wakeup detach
+step create_view: 
+	CREATE VIEW consumer AS
+	SELECT p.id AS parent_key, c.parent_id
+	FROM (SELECT id FROM parent WHERE id = filter_value()) p
+	LEFT JOIN child c FOR KEY (parent_id) -> p (id);
+ <waiting ...>
+step drop_function: 
+	DROP FUNCTION filter_value();
+
+step wakeup: 
+	SELECT FROM injection_points_wakeup('key-join-before-filter-dependency-lock');
+
+step create_view: <... completed>
+ERROR:  key join proof dependency function was concurrently dropped
+step detach: 
+	SELECT FROM injection_points_detach('key-join-before-filter-dependency-lock');
+
diff --git a/src/test/modules/injection_points/expected/key_join_proof_race_filter_operator_prelock.out b/src/test/modules/injection_points/expected/key_join_proof_race_filter_operator_prelock.out
new file mode 100644
index 00000000000..4bac8ab7653
--- /dev/null
+++ b/src/test/modules/injection_points/expected/key_join_proof_race_filter_operator_prelock.out
@@ -0,0 +1,23 @@
+Parsed test spec with 3 sessions
+
+starting permutation: create_view drop_operator wakeup wakeup_again detach
+step create_view: 
+	CREATE VIEW consumer AS
+	SELECT p.id AS parent_key, c.parent_id
+	FROM (SELECT id FROM parent WHERE id =## 1::bigint) p
+	LEFT JOIN child c FOR KEY (parent_id) -> p (id);
+ <waiting ...>
+step drop_operator: 
+	DROP OPERATOR =## (integer, bigint) CASCADE;
+
+step wakeup: 
+	SELECT FROM injection_points_wakeup('key-join-after-fkey-list-copy');
+
+step wakeup_again: 
+	SELECT FROM injection_points_wakeup('key-join-after-fkey-list-copy');
+
+step create_view: <... completed>
+ERROR:  key join proof dependency operator was concurrently dropped
+step detach: 
+	SELECT FROM injection_points_detach('key-join-after-fkey-list-copy');
+
diff --git a/src/test/modules/injection_points/expected/key_join_proof_race_function_prelock.out b/src/test/modules/injection_points/expected/key_join_proof_race_function_prelock.out
new file mode 100644
index 00000000000..b110c5ed845
--- /dev/null
+++ b/src/test/modules/injection_points/expected/key_join_proof_race_function_prelock.out
@@ -0,0 +1,26 @@
+Parsed test spec with 3 sessions
+
+starting permutation: create_view drop_function wakeup detach
+step create_view: 
+	CREATE VIEW consumer AS
+	SELECT p.id AS parent_key, c.parent_id
+	FROM parent_v p
+	LEFT JOIN child_v c FOR KEY (parent_id) -> p (id);
+ <waiting ...>
+step drop_function: 
+	DROP FUNCTION bucket_eq(integer, integer) CASCADE;
+ <waiting ...>
+step wakeup: 
+	SELECT FROM injection_points_wakeup('key-join-after-equality-operator-lock');
+
+step detach: 
+	SELECT FROM injection_points_detach('key-join-after-equality-operator-lock');
+
+ddl: NOTICE:  drop cascades to 4 other objects
+DETAIL:  drop cascades to operator =#(integer,integer)
+drop cascades to operator class bucket_int4_ops for access method btree
+drop cascades to index parent_bucket_idx
+drop cascades to constraint child_parent_id_fkey on table child
+step drop_function: <... completed>
+step create_view: <... completed>
+ERROR:  deadlock detected
diff --git a/src/test/modules/injection_points/expected/key_join_proof_race_indexes.out b/src/test/modules/injection_points/expected/key_join_proof_race_indexes.out
new file mode 100644
index 00000000000..3776cac2159
--- /dev/null
+++ b/src/test/modules/injection_points/expected/key_join_proof_race_indexes.out
@@ -0,0 +1,21 @@
+Parsed test spec with 3 sessions
+
+starting permutation: create_view drop_idx wakeup detach
+step create_view: 
+	CREATE VIEW consumer AS
+	SELECT pc.parent_id, pc.child_id, g.id AS grandchild_id
+	FROM parent_child_v pc
+	JOIN grandchild_v g FOR KEY (parent_id) -> pc (parent_id);
+ <waiting ...>
+step drop_idx: 
+	DROP INDEX CONCURRENTLY child_parent_id_bare_idx;
+ <waiting ...>
+step wakeup: 
+	SELECT FROM injection_points_wakeup('key-join-after-proof-match');
+
+step create_view: <... completed>
+step drop_idx: <... completed>
+ERROR:  cannot drop index child_parent_id_bare_idx because other objects depend on it
+step detach: 
+	SELECT FROM injection_points_detach('key-join-after-proof-match');
+
diff --git a/src/test/modules/injection_points/expected/key_join_proof_race_operator_cascade.out b/src/test/modules/injection_points/expected/key_join_proof_race_operator_cascade.out
new file mode 100644
index 00000000000..3e2f036eee0
--- /dev/null
+++ b/src/test/modules/injection_points/expected/key_join_proof_race_operator_cascade.out
@@ -0,0 +1,25 @@
+Parsed test spec with 3 sessions
+
+starting permutation: create_view drop_operator wakeup detach
+step create_view: 
+	CREATE VIEW consumer AS
+	SELECT p.id AS parent_key, c.id AS child_id, c.parent_id
+	FROM parent_v p
+	LEFT JOIN child_v c FOR KEY (parent_id) -> p (id);
+ <waiting ...>
+step drop_operator: 
+	DROP OPERATOR =# (integer, integer) CASCADE;
+ <waiting ...>
+step wakeup: 
+	SELECT FROM injection_points_wakeup('key-join-after-proof-match');
+
+step create_view: <... completed>
+ddl: NOTICE:  drop cascades to 4 other objects
+DETAIL:  drop cascades to operator class bucket_int4_ops for access method btree
+drop cascades to index parent_bucket_idx
+drop cascades to constraint child_parent_id_fkey on table child
+drop cascades to view consumer
+step drop_operator: <... completed>
+step detach: 
+	SELECT FROM injection_points_detach('key-join-after-proof-match');
+
diff --git a/src/test/modules/injection_points/expected/key_join_proof_race_operator_prelock.out b/src/test/modules/injection_points/expected/key_join_proof_race_operator_prelock.out
new file mode 100644
index 00000000000..24493cd5a9c
--- /dev/null
+++ b/src/test/modules/injection_points/expected/key_join_proof_race_operator_prelock.out
@@ -0,0 +1,25 @@
+Parsed test spec with 3 sessions
+
+starting permutation: create_view drop_operator wakeup detach
+step create_view: 
+	CREATE VIEW consumer AS
+	SELECT p.id AS parent_key, c.parent_id
+	FROM parent_v p
+	LEFT JOIN child_v c FOR KEY (parent_id) -> p (id);
+ <waiting ...>
+ddl: NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to operator class bucket_int4_ops for access method btree
+drop cascades to index parent_bucket_idx
+drop cascades to constraint child_parent_id_fkey on table child
+step drop_operator: 
+	DROP OPERATOR =# (integer, integer) CASCADE;
+ <waiting ...>
+step wakeup: 
+	SELECT FROM injection_points_wakeup('key-join-after-fkey-list-copy');
+
+step detach: 
+	SELECT FROM injection_points_detach('key-join-after-fkey-list-copy');
+
+step drop_operator: <... completed>
+step create_view: <... completed>
+ERROR:  deadlock detected
diff --git a/src/test/modules/injection_points/expected/key_join_proof_race_producer_view.out b/src/test/modules/injection_points/expected/key_join_proof_race_producer_view.out
new file mode 100644
index 00000000000..6e3c5df477f
--- /dev/null
+++ b/src/test/modules/injection_points/expected/key_join_proof_race_producer_view.out
@@ -0,0 +1,23 @@
+Parsed test spec with 3 sessions
+
+starting permutation: create_view replace_inner wakeup detach
+step create_view: 
+	CREATE VIEW consumer AS
+	SELECT p.id AS parent_id, c.id AS child_id,
+		   c.parent_id AS child_parent_id
+	FROM outer_parent_v p
+	JOIN child_v c FOR KEY (parent_id) -> p (id);
+ <waiting ...>
+step replace_inner: 
+	CREATE OR REPLACE VIEW inner_parent_v AS
+	SELECT id, visible FROM parent WHERE visible;
+ <waiting ...>
+step wakeup: 
+	SELECT FROM injection_points_wakeup('key-join-after-proof-match');
+
+step create_view: <... completed>
+step replace_inner: <... completed>
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+step detach: 
+	SELECT FROM injection_points_detach('key-join-after-proof-match');
+
diff --git a/src/test/modules/injection_points/expected/key_join_proof_race_record_proc_dep.out b/src/test/modules/injection_points/expected/key_join_proof_race_record_proc_dep.out
new file mode 100644
index 00000000000..935869b9ade
--- /dev/null
+++ b/src/test/modules/injection_points/expected/key_join_proof_race_record_proc_dep.out
@@ -0,0 +1,28 @@
+Parsed test spec with 3 sessions
+
+starting permutation: create_view ddl_begin drop_function wakeup ddl_commit detach
+step create_view: 
+	DO $$
+	BEGIN
+		EXECUTE $view$
+			CREATE VIEW consumer AS
+			SELECT p.id AS parent_key, p.marker, c.parent_id
+			FROM (SELECT id, ordinary_value() AS marker FROM parent) p
+			LEFT JOIN child c FOR KEY (parent_id) -> p (id)
+		$view$;
+	EXCEPTION WHEN undefined_object THEN
+		RAISE NOTICE 'function dependency disappeared before recording';
+	END
+	$$;
+ <waiting ...>
+step ddl_begin: BEGIN;
+step drop_function: 
+	DROP FUNCTION ordinary_value();
+
+step wakeup: 
+	SELECT FROM injection_points_wakeup('key-join-after-proof-match');
+
+step ddl_commit: COMMIT;
+creator: NOTICE:  function dependency disappeared before recording
+step create_view: <... completed>
+step detach: SELECT FROM injection_points_detach('key-join-after-proof-match');
diff --git a/src/test/modules/injection_points/expected/key_join_proof_race_relation_state.out b/src/test/modules/injection_points/expected/key_join_proof_race_relation_state.out
new file mode 100644
index 00000000000..36cdeaaee7c
--- /dev/null
+++ b/src/test/modules/injection_points/expected/key_join_proof_race_relation_state.out
@@ -0,0 +1,43 @@
+Parsed test spec with 3 sessions
+
+starting permutation: create_view enable_rls wakeup detach
+step create_view: 
+	CREATE VIEW consumer AS
+	SELECT p.id AS parent_id, c.id AS child_id,
+		   c.parent_id AS child_parent_id
+	FROM parent_v p
+	JOIN child_v c FOR KEY (parent_id) -> p (id);
+ <waiting ...>
+step enable_rls: 
+	ALTER TABLE parent ENABLE ROW LEVEL SECURITY;
+ <waiting ...>
+step wakeup: 
+	SELECT FROM injection_points_wakeup('key-join-after-proof-match');
+
+step create_view: <... completed>
+step enable_rls: <... completed>
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+step detach: 
+	SELECT FROM injection_points_detach('key-join-after-proof-match');
+
+
+starting permutation: create_view add_inherits wakeup detach
+step create_view: 
+	CREATE VIEW consumer AS
+	SELECT p.id AS parent_id, c.id AS child_id,
+		   c.parent_id AS child_parent_id
+	FROM parent_v p
+	JOIN child_v c FOR KEY (parent_id) -> p (id);
+ <waiting ...>
+step add_inherits: 
+	CREATE TABLE parent_child () INHERITS (parent);
+ <waiting ...>
+step wakeup: 
+	SELECT FROM injection_points_wakeup('key-join-after-proof-match');
+
+step create_view: <... completed>
+step add_inherits: <... completed>
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+step detach: 
+	SELECT FROM injection_points_detach('key-join-after-proof-match');
+
diff --git a/src/test/modules/injection_points/expected/key_join_stale_function_dependency.out b/src/test/modules/injection_points/expected/key_join_stale_function_dependency.out
new file mode 100644
index 00000000000..ce2db5877af
--- /dev/null
+++ b/src/test/modules/injection_points/expected/key_join_stale_function_dependency.out
@@ -0,0 +1,16 @@
+Parsed test spec with 3 sessions
+
+starting permutation: alter_fk drop_function wakeup detach
+step alter_fk: 
+	ALTER TABLE c ALTER CONSTRAINT c_parent_id_fkey NOT ENFORCED;
+ <waiting ...>
+step drop_function: 
+	DROP FUNCTION dep();
+
+step wakeup: 
+	SELECT FROM injection_points_wakeup('key-join-before-dependent-function-lookup');
+
+step alter_fk: <... completed>
+step detach: 
+	SELECT FROM injection_points_detach('key-join-before-dependent-function-lookup');
+
diff --git a/src/test/modules/injection_points/injection_points.c b/src/test/modules/injection_points/injection_points.c
index ba282e3dcab..99fd96f5fd4 100644
--- a/src/test/modules/injection_points/injection_points.c
+++ b/src/test/modules/injection_points/injection_points.c
@@ -31,6 +31,7 @@
 #include "utils/builtins.h"
 #include "utils/guc.h"
 #include "utils/injection_point.h"
+#include "utils/inval.h"
 #include "utils/memutils.h"
 #include "utils/tuplestore.h"
 #include "utils/wait_event.h"
@@ -80,6 +81,9 @@ extern PGDLLEXPORT void injection_notice(const char *name,
 extern PGDLLEXPORT void injection_wait(const char *name,
 									   const void *private_data,
 									   void *arg);
+extern PGDLLEXPORT void injection_invalidate_system_caches(const char *name,
+														   const void *private_data,
+														   void *arg);
 
 /* track if injection points attached in this process are linked to it */
 static bool injection_point_local = false;
@@ -286,6 +290,19 @@ injection_wait(const char *name, const void *private_data, void *arg)
 	SpinLockRelease(&inj_state->lock);
 }
 
+void
+injection_invalidate_system_caches(const char *name,
+								   const void *private_data, void *arg)
+{
+	const InjectionPointCondition *condition = private_data;
+
+	if (!injection_point_allowed(condition))
+		return;
+
+	InvalidateSystemCaches();
+	elog(NOTICE, "system caches invalidated for injection point %s", name);
+}
+
 /*
  * SQL function for creating an injection point.
  */
@@ -304,6 +321,8 @@ injection_points_attach(PG_FUNCTION_ARGS)
 		function = "injection_notice";
 	else if (strcmp(action, "wait") == 0)
 		function = "injection_wait";
+	else if (strcmp(action, "invalidate_system_caches") == 0)
+		function = "injection_invalidate_system_caches";
 	else
 		elog(ERROR, "incorrect action \"%s\" for injection point creation", action);
 
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index 59dba1cb023..7d9e612b64b 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -34,6 +34,7 @@ tests += {
     'sql': [
       'injection_points',
       'hashagg',
+      'key_join',
       'reindex_conc',
       'vacuum',
     ],
@@ -51,6 +52,17 @@ tests += {
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
+      'key_join_proof_race_constraints',
+      'key_join_proof_race_filter_function_prelock',
+      'key_join_proof_race_filter_operator_prelock',
+      'key_join_proof_race_function_prelock',
+      'key_join_proof_race_indexes',
+      'key_join_proof_race_operator_cascade',
+      'key_join_proof_race_operator_prelock',
+      'key_join_proof_race_producer_view',
+      'key_join_proof_race_record_proc_dep',
+      'key_join_proof_race_relation_state',
+      'key_join_stale_function_dependency',
     ],
     'runningcheck': false, # see syscache-update-pruned
     # Some tests wait for all snapshots, so avoid parallel execution
diff --git a/src/test/modules/injection_points/specs/key_join_proof_race_constraints.spec b/src/test/modules/injection_points/specs/key_join_proof_race_constraints.spec
new file mode 100644
index 00000000000..3c336f2b673
--- /dev/null
+++ b/src/test/modules/injection_points/specs/key_join_proof_race_constraints.spec
@@ -0,0 +1,81 @@
+# Key-join proof dependency races against constraint changes.
+#
+# CREATE VIEW pauses after selecting a key-join proof whose fact dependencies
+# are already locked.  Concurrent DDL then tries to remove one catalog fact
+# used by that proof and must wait behind dependency creation.
+
+setup
+{
+	CREATE EXTENSION injection_points;
+	CREATE SCHEMA key_join_proof_race_constraints;
+	CREATE TABLE key_join_proof_race_constraints.parent
+	(
+		id integer CONSTRAINT parent_pkey PRIMARY KEY
+	);
+	CREATE TABLE key_join_proof_race_constraints.child
+	(
+		id integer PRIMARY KEY,
+		parent_id integer CONSTRAINT child_parent_id_not_null NOT NULL,
+		CONSTRAINT child_parent_id_fkey
+			FOREIGN KEY (parent_id)
+			REFERENCES key_join_proof_race_constraints.parent (id)
+	);
+	CREATE VIEW key_join_proof_race_constraints.parent_v AS
+		SELECT id FROM key_join_proof_race_constraints.parent;
+	CREATE VIEW key_join_proof_race_constraints.child_v AS
+		SELECT id, parent_id FROM key_join_proof_race_constraints.child;
+}
+
+teardown
+{
+	DROP SCHEMA key_join_proof_race_constraints CASCADE;
+	DROP EXTENSION injection_points;
+}
+
+session creator
+setup
+{
+	SET search_path = key_join_proof_race_constraints, public;
+	SELECT FROM injection_points_set_local();
+	SELECT FROM injection_points_attach('key-join-after-proof-match', 'wait');
+}
+step create_view
+{
+	CREATE VIEW consumer AS
+	SELECT p.id AS parent_id, c.id AS child_id,
+		   c.parent_id AS child_parent_id
+	FROM parent_v p
+	LEFT JOIN child_v c FOR KEY (parent_id) -> p (id);
+}
+
+session ddl
+setup
+{
+	SET search_path = key_join_proof_race_constraints;
+}
+step drop_fk
+{
+	ALTER TABLE child DROP CONSTRAINT child_parent_id_fkey;
+}
+step drop_not_null
+{
+	ALTER TABLE child ALTER COLUMN parent_id DROP NOT NULL;
+}
+step drop_pk_cascade
+{
+	ALTER TABLE parent DROP CONSTRAINT parent_pkey CASCADE;
+}
+
+session ctl
+step wakeup
+{
+	SELECT FROM injection_points_wakeup('key-join-after-proof-match');
+}
+step detach
+{
+	SELECT FROM injection_points_detach('key-join-after-proof-match');
+}
+
+permutation create_view drop_fk wakeup detach
+permutation create_view drop_not_null wakeup detach
+permutation create_view drop_pk_cascade wakeup detach
diff --git a/src/test/modules/injection_points/specs/key_join_proof_race_filter_function_prelock.spec b/src/test/modules/injection_points/specs/key_join_proof_race_filter_function_prelock.spec
new file mode 100644
index 00000000000..13e486e7703
--- /dev/null
+++ b/src/test/modules/injection_points/specs/key_join_proof_race_filter_function_prelock.spec
@@ -0,0 +1,71 @@
+# Key-join proof race while acquiring filter-function proof locks.
+#
+# The parser pauses after collecting proof-filter dependencies, after parse
+# analysis has resolved a filter value function in the current query but before
+# key-join proof locks that function.  DROP FUNCTION can remove the function
+# before CREATE VIEW resumes, so proof validation must fail cleanly.
+
+setup
+{
+	CREATE EXTENSION injection_points;
+	CREATE SCHEMA key_join_proof_race_filter_function_prelock;
+	SET search_path = key_join_proof_race_filter_function_prelock;
+
+	CREATE TABLE parent
+	(
+		id integer PRIMARY KEY
+	);
+	CREATE TABLE child
+	(
+		parent_id integer NOT NULL REFERENCES parent (id)
+	);
+
+	CREATE FUNCTION filter_value() RETURNS integer
+		LANGUAGE sql IMMUTABLE AS $$
+		SELECT 1
+		$$;
+}
+
+teardown
+{
+	DROP SCHEMA key_join_proof_race_filter_function_prelock CASCADE;
+	DROP EXTENSION injection_points;
+}
+
+session creator
+setup
+{
+	SET search_path = key_join_proof_race_filter_function_prelock, public;
+	SELECT FROM injection_points_set_local();
+	SELECT FROM injection_points_attach('key-join-before-filter-dependency-lock',
+										'wait');
+}
+step create_view
+{
+	CREATE VIEW consumer AS
+	SELECT p.id AS parent_key, c.parent_id
+	FROM (SELECT id FROM parent WHERE id = filter_value()) p
+	LEFT JOIN child c FOR KEY (parent_id) -> p (id);
+}
+
+session ddl
+setup
+{
+	SET search_path = key_join_proof_race_filter_function_prelock;
+}
+step drop_function
+{
+	DROP FUNCTION filter_value();
+}
+
+session ctl
+step wakeup
+{
+	SELECT FROM injection_points_wakeup('key-join-before-filter-dependency-lock');
+}
+step detach
+{
+	SELECT FROM injection_points_detach('key-join-before-filter-dependency-lock');
+}
+
+permutation create_view drop_function wakeup detach
diff --git a/src/test/modules/injection_points/specs/key_join_proof_race_filter_operator_prelock.spec b/src/test/modules/injection_points/specs/key_join_proof_race_filter_operator_prelock.spec
new file mode 100644
index 00000000000..a7bbe521591
--- /dev/null
+++ b/src/test/modules/injection_points/specs/key_join_proof_race_filter_operator_prelock.spec
@@ -0,0 +1,87 @@
+# Key-join proof race while acquiring filter-operator proof locks.
+#
+# The parser pauses after copying FK relcache state, after parse analysis has
+# resolved a filter operator in the current query but before key-join proof
+# locks that operator.  DROP OPERATOR ... CASCADE can remove the operator
+# before CREATE VIEW resumes, so proof validation must fail cleanly.
+
+setup
+{
+	CREATE EXTENSION injection_points;
+	CREATE SCHEMA key_join_proof_race_filter_operator_prelock;
+	SET search_path = key_join_proof_race_filter_operator_prelock;
+
+	CREATE TABLE parent
+	(
+		id integer PRIMARY KEY
+	);
+	CREATE TABLE child
+	(
+		parent_id integer NOT NULL REFERENCES parent (id)
+	);
+
+	CREATE FUNCTION filter_eq(a integer, b bigint) RETURNS boolean
+		LANGUAGE sql IMMUTABLE STRICT AS $$
+		SELECT a = b::integer
+		$$;
+	CREATE OPERATOR =## (
+		LEFTARG = integer,
+		RIGHTARG = bigint,
+		FUNCTION = filter_eq
+	);
+	CREATE OPERATOR FAMILY filter_hash_ops USING hash;
+	CREATE OPERATOR CLASS filter_hash_int4_ops
+		FOR TYPE integer USING hash FAMILY filter_hash_ops AS
+		OPERATOR 1 = (integer, integer),
+		FUNCTION 1 hashint4(integer);
+	ALTER OPERATOR FAMILY filter_hash_ops USING hash ADD
+		OPERATOR 1 =## (integer, bigint),
+		FUNCTION 1 hashint8(bigint);
+}
+
+teardown
+{
+	DROP SCHEMA key_join_proof_race_filter_operator_prelock CASCADE;
+	DROP EXTENSION injection_points;
+}
+
+session creator
+setup
+{
+	SET search_path = key_join_proof_race_filter_operator_prelock, public;
+	SELECT FROM injection_points_set_local();
+	SELECT FROM injection_points_attach('key-join-after-fkey-list-copy', 'wait');
+}
+step create_view
+{
+	CREATE VIEW consumer AS
+	SELECT p.id AS parent_key, c.parent_id
+	FROM (SELECT id FROM parent WHERE id =## 1::bigint) p
+	LEFT JOIN child c FOR KEY (parent_id) -> p (id);
+}
+
+session ddl
+setup
+{
+	SET search_path = key_join_proof_race_filter_operator_prelock;
+}
+step drop_operator
+{
+	DROP OPERATOR =## (integer, bigint) CASCADE;
+}
+
+session ctl
+step wakeup
+{
+	SELECT FROM injection_points_wakeup('key-join-after-fkey-list-copy');
+}
+step wakeup_again
+{
+	SELECT FROM injection_points_wakeup('key-join-after-fkey-list-copy');
+}
+step detach
+{
+	SELECT FROM injection_points_detach('key-join-after-fkey-list-copy');
+}
+
+permutation create_view drop_operator wakeup wakeup_again detach
diff --git a/src/test/modules/injection_points/specs/key_join_proof_race_function_prelock.spec b/src/test/modules/injection_points/specs/key_join_proof_race_function_prelock.spec
new file mode 100644
index 00000000000..ab0f34093a4
--- /dev/null
+++ b/src/test/modules/injection_points/specs/key_join_proof_race_function_prelock.spec
@@ -0,0 +1,101 @@
+# Key-join proof race while acquiring equality function proof locks.
+#
+# The parser pauses after locking the equality operator but before locking the
+# underlying function.  DROP FUNCTION ... CASCADE can take the function
+# deletion lock first, then wait for the operator lock held by CREATE VIEW.
+# When CREATE VIEW resumes, it blocks on the function lock; deadlock detection
+# resolves the race.
+
+setup
+{
+	CREATE EXTENSION injection_points;
+	CREATE SCHEMA key_join_proof_race_function_prelock;
+	SET search_path = key_join_proof_race_function_prelock;
+
+	CREATE FUNCTION bucket_cmp(a integer, b integer) RETURNS integer
+		LANGUAGE sql IMMUTABLE STRICT AS $$
+		SELECT CASE WHEN a / 10 < b / 10 THEN -1
+					WHEN a / 10 > b / 10 THEN 1
+					ELSE 0 END
+		$$;
+	CREATE FUNCTION bucket_eq(a integer, b integer) RETURNS boolean
+		LANGUAGE sql IMMUTABLE STRICT AS $$
+		SELECT a / 10 = b / 10
+		$$;
+	CREATE OPERATOR =# (
+		LEFTARG = integer,
+		RIGHTARG = integer,
+		FUNCTION = bucket_eq,
+		COMMUTATOR = =#,
+		RESTRICT = eqsel,
+		JOIN = eqjoinsel,
+		MERGES
+	);
+	CREATE OPERATOR CLASS bucket_int4_ops
+		FOR TYPE integer USING btree AS
+		OPERATOR 3 =# (integer, integer),
+		FUNCTION 1 bucket_cmp(integer, integer);
+
+	CREATE TABLE parent
+	(
+		id integer NOT NULL
+	);
+	CREATE UNIQUE INDEX parent_bucket_idx
+		ON parent USING btree (id bucket_int4_ops);
+	CREATE TABLE child
+	(
+		parent_id integer NOT NULL REFERENCES parent (id)
+	);
+	CREATE VIEW parent_v AS SELECT id FROM parent;
+	CREATE VIEW child_v AS SELECT parent_id FROM child;
+}
+
+teardown
+{
+	DROP SCHEMA key_join_proof_race_function_prelock CASCADE;
+	DROP EXTENSION injection_points;
+}
+
+session creator
+setup
+{
+	SET search_path = key_join_proof_race_function_prelock, public;
+	/*
+	 * The creator closes the deadlock cycle after wakeup.  Give it
+	 * the shorter timeout so victim selection is deterministic.
+	 */
+	SET deadlock_timeout = '100ms';
+	SELECT FROM injection_points_set_local();
+	SELECT FROM injection_points_attach('key-join-after-equality-operator-lock', 'wait');
+}
+step create_view
+{
+	CREATE VIEW consumer AS
+	SELECT p.id AS parent_key, c.parent_id
+	FROM parent_v p
+	LEFT JOIN child_v c FOR KEY (parent_id) -> p (id);
+}
+
+session ddl
+setup
+{
+	SET search_path = key_join_proof_race_function_prelock;
+	/* The DDL session must not win the deadlock-detection race. */
+	SET deadlock_timeout = '10s';
+}
+step drop_function
+{
+	DROP FUNCTION bucket_eq(integer, integer) CASCADE;
+}
+
+session ctl
+step wakeup
+{
+	SELECT FROM injection_points_wakeup('key-join-after-equality-operator-lock');
+}
+step detach
+{
+	SELECT FROM injection_points_detach('key-join-after-equality-operator-lock');
+}
+
+permutation create_view(drop_function) drop_function wakeup detach
diff --git a/src/test/modules/injection_points/specs/key_join_proof_race_indexes.spec b/src/test/modules/injection_points/specs/key_join_proof_race_indexes.spec
new file mode 100644
index 00000000000..01de8ff86d9
--- /dev/null
+++ b/src/test/modules/injection_points/specs/key_join_proof_race_indexes.spec
@@ -0,0 +1,86 @@
+# Key-join proof dependency race against a bare unique index drop.
+#
+# The producer view parent_child_v already contains one key join.  The
+# consumer view has a single key join whose proof of parent_child_v.parent_id
+# depends on the bare unique index on child(parent_id), while the foreign key
+# proof input is backed by parent_pkey.  DROP INDEX CONCURRENTLY must not pass
+# through the dependency gap before the consumer dependency is visible.
+
+setup
+{
+	CREATE EXTENSION injection_points;
+	CREATE SCHEMA key_join_proof_race_indexes;
+	CREATE TABLE key_join_proof_race_indexes.parent
+	(
+		id integer PRIMARY KEY
+	);
+	CREATE TABLE key_join_proof_race_indexes.child
+	(
+		id integer PRIMARY KEY,
+		parent_id integer NOT NULL
+			REFERENCES key_join_proof_race_indexes.parent (id)
+	);
+	CREATE UNIQUE INDEX child_parent_id_bare_idx
+		ON key_join_proof_race_indexes.child (parent_id);
+	CREATE TABLE key_join_proof_race_indexes.grandchild
+	(
+		id integer PRIMARY KEY,
+		parent_id integer NOT NULL
+			REFERENCES key_join_proof_race_indexes.parent (id)
+	);
+	CREATE VIEW key_join_proof_race_indexes.parent_v AS
+		SELECT id FROM key_join_proof_race_indexes.parent;
+	CREATE VIEW key_join_proof_race_indexes.child_v AS
+		SELECT id, parent_id FROM key_join_proof_race_indexes.child;
+	CREATE VIEW key_join_proof_race_indexes.grandchild_v AS
+		SELECT id, parent_id FROM key_join_proof_race_indexes.grandchild;
+	CREATE VIEW key_join_proof_race_indexes.parent_child_v AS
+		SELECT p.id AS parent_id, c.id AS child_id,
+			   c.parent_id AS child_parent_id
+		FROM key_join_proof_race_indexes.parent_v p
+		LEFT JOIN key_join_proof_race_indexes.child_v c
+			FOR KEY (parent_id) -> p (id);
+}
+
+teardown
+{
+	DROP SCHEMA key_join_proof_race_indexes CASCADE;
+	DROP EXTENSION injection_points;
+}
+
+session creator
+setup
+{
+	SET search_path = key_join_proof_race_indexes, public;
+	SELECT FROM injection_points_set_local();
+	SELECT FROM injection_points_attach('key-join-after-proof-match', 'wait');
+}
+step create_view
+{
+	CREATE VIEW consumer AS
+	SELECT pc.parent_id, pc.child_id, g.id AS grandchild_id
+	FROM parent_child_v pc
+	JOIN grandchild_v g FOR KEY (parent_id) -> pc (parent_id);
+}
+
+session ddl
+setup
+{
+	SET search_path = key_join_proof_race_indexes;
+}
+step drop_idx
+{
+	DROP INDEX CONCURRENTLY child_parent_id_bare_idx;
+}
+
+session ctl
+step wakeup
+{
+	SELECT FROM injection_points_wakeup('key-join-after-proof-match');
+}
+step detach
+{
+	SELECT FROM injection_points_detach('key-join-after-proof-match');
+}
+
+permutation create_view drop_idx wakeup detach
diff --git a/src/test/modules/injection_points/specs/key_join_proof_race_operator_cascade.spec b/src/test/modules/injection_points/specs/key_join_proof_race_operator_cascade.spec
new file mode 100644
index 00000000000..2c250d3b456
--- /dev/null
+++ b/src/test/modules/injection_points/specs/key_join_proof_race_operator_cascade.spec
@@ -0,0 +1,93 @@
+# Key-join proof dependency race against operator cascade.
+#
+# The selected proof uses a custom equality operator through a custom btree
+# opclass, a bare unique index, and the FK that targets it.  Dropping the
+# operator cascades through those proof inputs.
+
+setup
+{
+	CREATE EXTENSION injection_points;
+	CREATE SCHEMA key_join_proof_race_operator_cascade;
+	SET search_path = key_join_proof_race_operator_cascade;
+
+	CREATE FUNCTION bucket_cmp(a integer, b integer) RETURNS integer
+		LANGUAGE sql IMMUTABLE STRICT AS $$
+		SELECT CASE WHEN a / 10 < b / 10 THEN -1
+					WHEN a / 10 > b / 10 THEN 1
+					ELSE 0 END
+		$$;
+	CREATE FUNCTION bucket_eq(a integer, b integer) RETURNS boolean
+		LANGUAGE sql IMMUTABLE STRICT AS $$
+		SELECT a / 10 = b / 10
+		$$;
+	CREATE OPERATOR =# (
+		LEFTARG = integer,
+		RIGHTARG = integer,
+		FUNCTION = bucket_eq,
+		COMMUTATOR = =#,
+		RESTRICT = eqsel,
+		JOIN = eqjoinsel,
+		MERGES
+	);
+	CREATE OPERATOR CLASS bucket_int4_ops
+		FOR TYPE integer USING btree AS
+		OPERATOR 3 =# (integer, integer),
+		FUNCTION 1 bucket_cmp(integer, integer);
+
+	CREATE TABLE parent
+	(
+		id integer NOT NULL
+	);
+	CREATE UNIQUE INDEX parent_bucket_idx
+		ON parent USING btree (id bucket_int4_ops);
+	CREATE TABLE child
+	(
+		id integer PRIMARY KEY,
+		parent_id integer NOT NULL REFERENCES parent (id)
+	);
+	CREATE VIEW parent_v AS SELECT id FROM parent;
+	CREATE VIEW child_v AS SELECT id, parent_id FROM child;
+}
+
+teardown
+{
+	DROP SCHEMA key_join_proof_race_operator_cascade CASCADE;
+	DROP EXTENSION injection_points;
+}
+
+session creator
+setup
+{
+	SET search_path = key_join_proof_race_operator_cascade, public;
+	SELECT FROM injection_points_set_local();
+	SELECT FROM injection_points_attach('key-join-after-proof-match', 'wait');
+}
+step create_view
+{
+	CREATE VIEW consumer AS
+	SELECT p.id AS parent_key, c.id AS child_id, c.parent_id
+	FROM parent_v p
+	LEFT JOIN child_v c FOR KEY (parent_id) -> p (id);
+}
+
+session ddl
+setup
+{
+	SET search_path = key_join_proof_race_operator_cascade;
+}
+step drop_operator
+{
+	DROP OPERATOR =# (integer, integer) CASCADE;
+}
+
+session ctl
+step wakeup
+{
+	SELECT FROM injection_points_wakeup('key-join-after-proof-match');
+}
+step detach
+{
+	SELECT FROM injection_points_detach('key-join-after-proof-match');
+}
+
+permutation create_view drop_operator wakeup detach
diff --git a/src/test/modules/injection_points/specs/key_join_proof_race_operator_prelock.spec b/src/test/modules/injection_points/specs/key_join_proof_race_operator_prelock.spec
new file mode 100644
index 00000000000..f8988e4d164
--- /dev/null
+++ b/src/test/modules/injection_points/specs/key_join_proof_race_operator_prelock.spec
@@ -0,0 +1,101 @@
+# Key-join proof race while acquiring equality-operator proof locks.
+#
+# The parser pauses after copying FK relcache state but before locking the FK
+# equality operator.  DROP OPERATOR ... CASCADE can take the operator deletion
+# lock first, then wait for relation locks held by the in-flight CREATE VIEW.
+# When CREATE VIEW resumes, it blocks on the operator lock; deadlock detection
+# resolves the race.
+
+setup
+{
+	CREATE EXTENSION injection_points;
+	CREATE SCHEMA key_join_proof_race_operator_prelock;
+	SET search_path = key_join_proof_race_operator_prelock;
+
+	CREATE FUNCTION bucket_cmp(a integer, b integer) RETURNS integer
+		LANGUAGE sql IMMUTABLE STRICT AS $$
+		SELECT CASE WHEN a / 10 < b / 10 THEN -1
+					WHEN a / 10 > b / 10 THEN 1
+					ELSE 0 END
+		$$;
+	CREATE FUNCTION bucket_eq(a integer, b integer) RETURNS boolean
+		LANGUAGE sql IMMUTABLE STRICT AS $$
+		SELECT a / 10 = b / 10
+		$$;
+	CREATE OPERATOR =# (
+		LEFTARG = integer,
+		RIGHTARG = integer,
+		FUNCTION = bucket_eq,
+		COMMUTATOR = =#,
+		RESTRICT = eqsel,
+		JOIN = eqjoinsel,
+		MERGES
+	);
+	CREATE OPERATOR CLASS bucket_int4_ops
+		FOR TYPE integer USING btree AS
+		OPERATOR 3 =# (integer, integer),
+		FUNCTION 1 bucket_cmp(integer, integer);
+
+	CREATE TABLE parent
+	(
+		id integer NOT NULL
+	);
+	CREATE UNIQUE INDEX parent_bucket_idx
+		ON parent USING btree (id bucket_int4_ops);
+	CREATE TABLE child
+	(
+		parent_id integer NOT NULL REFERENCES parent (id)
+	);
+	CREATE VIEW parent_v AS SELECT id FROM parent;
+	CREATE VIEW child_v AS SELECT parent_id FROM child;
+}
+
+teardown
+{
+	DROP SCHEMA key_join_proof_race_operator_prelock CASCADE;
+	DROP EXTENSION injection_points;
+}
+
+session creator
+setup
+{
+	SET search_path = key_join_proof_race_operator_prelock, public;
+	/*
+	 * The creator closes the deadlock cycle after wakeup.  Give it
+	 * the shorter timeout so victim selection is deterministic.
+	 */
+	SET deadlock_timeout = '100ms';
+	SELECT FROM injection_points_set_local();
+	SELECT FROM injection_points_attach('key-join-after-fkey-list-copy', 'wait');
+}
+step create_view
+{
+	CREATE VIEW consumer AS
+	SELECT p.id AS parent_key, c.parent_id
+	FROM parent_v p
+	LEFT JOIN child_v c FOR KEY (parent_id) -> p (id);
+}
+
+session ddl
+setup
+{
+	SET search_path = key_join_proof_race_operator_prelock;
+	/* The DDL session must not win the deadlock-detection race. */
+	SET deadlock_timeout = '10s';
+}
+step drop_operator
+{
+	DROP OPERATOR =# (integer, integer) CASCADE;
+}
+
+session ctl
+step wakeup
+{
+	SELECT FROM injection_points_wakeup('key-join-after-fkey-list-copy');
+}
+step detach
+{
+	SELECT FROM injection_points_detach('key-join-after-fkey-list-copy');
+}
+
+permutation create_view(drop_operator) drop_operator wakeup detach
diff --git a/src/test/modules/injection_points/specs/key_join_proof_race_producer_view.spec b/src/test/modules/injection_points/specs/key_join_proof_race_producer_view.spec
new file mode 100644
index 00000000000..d16141b1922
--- /dev/null
+++ b/src/test/modules/injection_points/specs/key_join_proof_race_producer_view.spec
@@ -0,0 +1,73 @@
+# Key-join proof dependency race against a transitive producer view change.
+#
+# The consumer reads outer_parent_v, whose key-join facts are projected through
+# inner_parent_v.  Replacing inner_parent_v with a filtered definition destroys
+# the row-coverage proof that the consumer selected.
+
+setup
+{
+	CREATE EXTENSION injection_points;
+	CREATE SCHEMA key_join_proof_race_producer_view;
+	CREATE TABLE key_join_proof_race_producer_view.parent
+	(
+		id integer PRIMARY KEY,
+		visible boolean NOT NULL
+	);
+	CREATE TABLE key_join_proof_race_producer_view.child
+	(
+		id integer PRIMARY KEY,
+		parent_id integer NOT NULL
+			REFERENCES key_join_proof_race_producer_view.parent (id)
+	);
+	CREATE VIEW key_join_proof_race_producer_view.inner_parent_v AS
+		SELECT id, visible FROM key_join_proof_race_producer_view.parent;
+	CREATE VIEW key_join_proof_race_producer_view.outer_parent_v AS
+		SELECT id FROM key_join_proof_race_producer_view.inner_parent_v;
+	CREATE VIEW key_join_proof_race_producer_view.child_v AS
+		SELECT id, parent_id FROM key_join_proof_race_producer_view.child;
+}
+
+teardown
+{
+	DROP SCHEMA key_join_proof_race_producer_view CASCADE;
+	DROP EXTENSION injection_points;
+}
+
+session creator
+setup
+{
+	SET search_path = key_join_proof_race_producer_view, public;
+	SELECT FROM injection_points_set_local();
+	SELECT FROM injection_points_attach('key-join-after-proof-match', 'wait');
+}
+step create_view
+{
+	CREATE VIEW consumer AS
+	SELECT p.id AS parent_id, c.id AS child_id,
+		   c.parent_id AS child_parent_id
+	FROM outer_parent_v p
+	JOIN child_v c FOR KEY (parent_id) -> p (id);
+}
+
+session ddl
+setup
+{
+	SET search_path = key_join_proof_race_producer_view;
+}
+step replace_inner
+{
+	CREATE OR REPLACE VIEW inner_parent_v AS
+	SELECT id, visible FROM parent WHERE visible;
+}
+
+session ctl
+step wakeup
+{
+	SELECT FROM injection_points_wakeup('key-join-after-proof-match');
+}
+step detach
+{
+	SELECT FROM injection_points_detach('key-join-after-proof-match');
+}
+
+permutation create_view replace_inner wakeup detach
diff --git a/src/test/modules/injection_points/specs/key_join_proof_race_record_proc_dep.spec b/src/test/modules/injection_points/specs/key_join_proof_race_record_proc_dep.spec
new file mode 100644
index 00000000000..e0c5074ac77
--- /dev/null
+++ b/src/test/modules/injection_points/specs/key_join_proof_race_record_proc_dep.spec
@@ -0,0 +1,78 @@
+# Key-join proof race while recording an ordinary function dependency.
+#
+# The parser pauses after accepting the key-join proof.  At that point the
+# left subquery's ordinary function reference has already been resolved, but
+# CREATE VIEW has not yet recorded the rewrite rule's dependencies.  Dropping
+# that function before CREATE VIEW resumes must fail cleanly when dependency
+# recording tries to lock the stale function OID.
+
+setup
+{
+	CREATE EXTENSION injection_points;
+	CREATE SCHEMA key_join_proof_race_record_proc_dep;
+	SET search_path = key_join_proof_race_record_proc_dep;
+
+	CREATE TABLE parent
+	(
+		id integer PRIMARY KEY
+	);
+	CREATE TABLE child
+	(
+		parent_id integer NOT NULL REFERENCES parent (id)
+	);
+
+	CREATE FUNCTION ordinary_value() RETURNS integer
+		LANGUAGE sql IMMUTABLE AS $$
+		SELECT 1
+	$$;
+}
+
+teardown
+{
+	DROP SCHEMA key_join_proof_race_record_proc_dep CASCADE;
+	DROP EXTENSION injection_points;
+}
+
+session creator
+setup
+{
+	SET search_path = key_join_proof_race_record_proc_dep, public;
+	SELECT FROM injection_points_set_local();
+	SELECT FROM injection_points_attach('key-join-after-proof-match', 'wait');
+}
+step create_view
+{
+	DO $$
+	BEGIN
+		EXECUTE $view$
+			CREATE VIEW consumer AS
+			SELECT p.id AS parent_key, p.marker, c.parent_id
+			FROM (SELECT id, ordinary_value() AS marker FROM parent) p
+			LEFT JOIN child c FOR KEY (parent_id) -> p (id)
+		$view$;
+	EXCEPTION WHEN undefined_object THEN
+		RAISE NOTICE 'function dependency disappeared before recording';
+	END
+	$$;
+}
+
+session ddl
+setup
+{
+	SET search_path = key_join_proof_race_record_proc_dep;
+}
+step ddl_begin { BEGIN; }
+step drop_function
+{
+	DROP FUNCTION ordinary_value();
+}
+step ddl_commit { COMMIT; }
+
+session ctl
+step wakeup
+{
+	SELECT FROM injection_points_wakeup('key-join-after-proof-match');
+}
+step detach { SELECT FROM injection_points_detach('key-join-after-proof-match'); }
+
+permutation create_view ddl_begin drop_function wakeup ddl_commit detach
diff --git a/src/test/modules/injection_points/specs/key_join_proof_race_relation_state.spec b/src/test/modules/injection_points/specs/key_join_proof_race_relation_state.spec
new file mode 100644
index 00000000000..59b5d42520b
--- /dev/null
+++ b/src/test/modules/injection_points/specs/key_join_proof_race_relation_state.spec
@@ -0,0 +1,73 @@
+# Key-join proof dependency races against base relation state changes.
+#
+# Relation-level facts are valid only while row security and inheritance shape
+# still expose the rows used by the proof.
+
+setup
+{
+	CREATE EXTENSION injection_points;
+	CREATE SCHEMA key_join_proof_race_relation_state;
+	CREATE TABLE key_join_proof_race_relation_state.parent
+	(
+		id integer PRIMARY KEY
+	);
+	CREATE TABLE key_join_proof_race_relation_state.child
+	(
+		id integer PRIMARY KEY,
+		parent_id integer NOT NULL
+			REFERENCES key_join_proof_race_relation_state.parent (id)
+	);
+	CREATE VIEW key_join_proof_race_relation_state.parent_v AS
+		SELECT id FROM key_join_proof_race_relation_state.parent;
+	CREATE VIEW key_join_proof_race_relation_state.child_v AS
+		SELECT id, parent_id FROM key_join_proof_race_relation_state.child;
+}
+
+teardown
+{
+	DROP SCHEMA key_join_proof_race_relation_state CASCADE;
+	DROP EXTENSION injection_points;
+}
+
+session creator
+setup
+{
+	SET search_path = key_join_proof_race_relation_state, public;
+	SELECT FROM injection_points_set_local();
+	SELECT FROM injection_points_attach('key-join-after-proof-match', 'wait');
+}
+step create_view
+{
+	CREATE VIEW consumer AS
+	SELECT p.id AS parent_id, c.id AS child_id,
+		   c.parent_id AS child_parent_id
+	FROM parent_v p
+	JOIN child_v c FOR KEY (parent_id) -> p (id);
+}
+
+session ddl
+setup
+{
+	SET search_path = key_join_proof_race_relation_state;
+}
+step enable_rls
+{
+	ALTER TABLE parent ENABLE ROW LEVEL SECURITY;
+}
+step add_inherits
+{
+	CREATE TABLE parent_child () INHERITS (parent);
+}
+
+session ctl
+step wakeup
+{
+	SELECT FROM injection_points_wakeup('key-join-after-proof-match');
+}
+step detach
+{
+	SELECT FROM injection_points_detach('key-join-after-proof-match');
+}
+
+permutation create_view enable_rls wakeup detach
+permutation create_view add_inherits wakeup detach
diff --git a/src/test/modules/injection_points/specs/key_join_stale_function_dependency.spec b/src/test/modules/injection_points/specs/key_join_stale_function_dependency.spec
new file mode 100644
index 00000000000..d48b3cad96e
--- /dev/null
+++ b/src/test/modules/injection_points/specs/key_join_stale_function_dependency.spec
@@ -0,0 +1,75 @@
+# Key-join revalidation races against a dropped SQL-body function.
+#
+# Constraint changes revalidate stored key-join proofs after scanning
+# pg_depend.  If a dependent SQL function disappears before its pg_proc row is
+# looked up, the stale dependency should be ignored instead of reaching the old
+# assertion path.
+
+setup
+{
+	CREATE EXTENSION injection_points;
+	CREATE SCHEMA key_join_stale_function_dependency;
+	SET search_path = key_join_stale_function_dependency;
+
+	CREATE TABLE p
+	(
+		id integer PRIMARY KEY
+	);
+	CREATE TABLE c
+	(
+		id integer PRIMARY KEY,
+		parent_id integer CONSTRAINT c_parent_id_not_null NOT NULL,
+		CONSTRAINT c_parent_id_fkey
+			FOREIGN KEY (parent_id)
+			REFERENCES p (id)
+	);
+
+	CREATE FUNCTION dep() RETURNS TABLE(pid integer, cid integer)
+		LANGUAGE sql
+		BEGIN ATOMIC
+			SELECT p.id, c.id
+			FROM p
+			JOIN c FOR KEY (parent_id) -> p (id);
+		END;
+}
+
+teardown
+{
+	DROP SCHEMA key_join_stale_function_dependency CASCADE;
+	DROP EXTENSION injection_points;
+}
+
+session alterer
+setup
+{
+	SET search_path = key_join_stale_function_dependency, public;
+	SELECT FROM injection_points_set_local();
+	SELECT FROM injection_points_attach('key-join-before-dependent-function-lookup',
+										'wait');
+}
+step alter_fk
+{
+	ALTER TABLE c ALTER CONSTRAINT c_parent_id_fkey NOT ENFORCED;
+}
+
+session ddl
+setup
+{
+	SET search_path = key_join_stale_function_dependency;
+}
+step drop_function
+{
+	DROP FUNCTION dep();
+}
+
+session ctl
+step wakeup
+{
+	SELECT FROM injection_points_wakeup('key-join-before-dependent-function-lookup');
+}
+step detach
+{
+	SELECT FROM injection_points_detach('key-join-before-dependent-function-lookup');
+}
+
+permutation alter_fk drop_function wakeup detach
diff --git a/src/test/modules/injection_points/sql/key_join.sql b/src/test/modules/injection_points/sql/key_join.sql
new file mode 100644
index 00000000000..f2b0f2f0587
--- /dev/null
+++ b/src/test/modules/injection_points/sql/key_join.sql
@@ -0,0 +1,37 @@
+CREATE EXTENSION injection_points;
+
+SELECT injection_points_set_local();
+
+CREATE SCHEMA injection_point_key_join;
+CREATE TABLE injection_point_key_join.parent
+(
+    id int PRIMARY KEY,
+    visible text
+);
+CREATE TABLE injection_point_key_join.child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL
+        REFERENCES injection_point_key_join.parent (id),
+    visible text
+);
+
+INSERT INTO injection_point_key_join.parent VALUES (1, 'parent-one');
+INSERT INTO injection_point_key_join.child VALUES (10, 1, 'child-one');
+
+-- Populate the relcache FK list before attaching the injection point.
+SELECT p.visible AS parent_visible, c.visible AS child_visible
+FROM injection_point_key_join.parent p
+JOIN injection_point_key_join.child c FOR KEY (parent_id) -> p (id);
+
+SELECT injection_points_attach('key-join-after-fkey-list-copy',
+                               'invalidate_system_caches');
+
+SELECT p.visible AS parent_visible, c.visible AS child_visible
+FROM injection_point_key_join.parent p
+JOIN injection_point_key_join.child c FOR KEY (parent_id) -> p (id);
+
+SELECT injection_points_detach('key-join-after-fkey-list-copy');
+
+DROP SCHEMA injection_point_key_join CASCADE;
+DROP EXTENSION injection_points;
diff --git a/src/test/regress/expected/collate.icu.utf8.out b/src/test/regress/expected/collate.icu.utf8.out
index 04e2f6df037..aefe1e4ce83 100644
--- a/src/test/regress/expected/collate.icu.utf8.out
+++ b/src/test/regress/expected/collate.icu.utf8.out
@@ -2812,6 +2812,21 @@ SELECT * FROM test12fk;  -- was updated
 (2 rows)
 
 DROP TABLE test12pk, test12fk;
+-- key join proof rejects nondeterministic collations.  PostgreSQL accepts the
+-- FK, but key join does not use nondeterministic equality as proof evidence.
+CREATE TABLE keyjoin_nondet_pk (x text COLLATE case_insensitive PRIMARY KEY);
+CREATE TABLE keyjoin_nondet_fk
+  (a int PRIMARY KEY, b text COLLATE case_insensitive NOT NULL REFERENCES keyjoin_nondet_pk (x));
+INSERT INTO keyjoin_nondet_pk VALUES ('abc');
+INSERT INTO keyjoin_nondet_fk VALUES (1, 'ABC');
+SELECT *
+FROM keyjoin_nondet_pk p
+JOIN keyjoin_nondet_fk f FOR KEY (b) -> p (x);
+ERROR:  key join from referencing relation f to referenced relation p cannot be proven
+LINE 3: JOIN keyjoin_nondet_fk f FOR KEY (b) -> p (x);
+                                 ^
+DETAIL:  There is no matching foreign key constraint for f (b) referencing p (x).
+DROP TABLE keyjoin_nondet_fk, keyjoin_nondet_pk;
 -- partitioning
 CREATE TABLE test20 (a int, b text COLLATE case_insensitive) PARTITION BY LIST (b);
 CREATE TABLE test20_1 PARTITION OF test20 FOR VALUES IN ('abc');
diff --git a/src/test/regress/expected/key_join.out b/src/test/regress/expected/key_join.out
new file mode 100644
index 00000000000..0fafe10cb11
--- /dev/null
+++ b/src/test/regress/expected/key_join.out
@@ -0,0 +1,6603 @@
+--
+-- Test Foreign Key Joins.
+--
+CREATE TABLE t1
+(
+    c1 int not null,
+    c2 int not null,
+    CONSTRAINT t1_pkey PRIMARY KEY (c1)
+);
+CREATE TABLE t2
+(
+    c3 int not null,
+    c4 int not null,
+    CONSTRAINT t2_pkey PRIMARY KEY (c3),
+    CONSTRAINT t2_c3_fkey FOREIGN KEY (c3) REFERENCES t1 (c1)
+);
+INSERT INTO t1 (c1, c2) VALUES (1, 10);
+INSERT INTO t1 (c1, c2) VALUES (2, 20);
+INSERT INTO t1 (c1, c2) VALUES (3, 30);
+INSERT INTO t2 (c3, c4) VALUES (1, 10);
+INSERT INTO t2 (c3, c4) VALUES (3, 30);
+--
+-- Test renaming tables and columns.
+--
+CREATE VIEW v1 AS
+SELECT *
+FROM t1
+-- accepted
+JOIN t2 FOR KEY (c3) -> t1 (c1);
+\d+ v1
+                             View "public.v1"
+ Column |  Type   | Collation | Nullable | Default | Storage | Description 
+--------+---------+-----------+----------+---------+---------+-------------
+ c1     | integer |           |          |         | plain   | 
+ c2     | integer |           |          |         | plain   | 
+ c3     | integer |           |          |         | plain   | 
+ c4     | integer |           |          |         | plain   | 
+View definition:
+ SELECT t1.c1,
+    t1.c2,
+    t2.c3,
+    t2.c4
+   FROM t1
+     JOIN t2 FOR KEY (c3) -> t1 (c1);
+
+SELECT * FROM v1; -- accepted
+ c1 | c2 | c3 | c4 
+----+----+----+----
+  1 | 10 |  1 | 10
+  3 | 30 |  3 | 30
+(2 rows)
+
+ALTER TABLE t1 RENAME COLUMN c1 TO c1_renamed;
+ALTER TABLE t2 RENAME COLUMN c3 TO c3_renamed;
+ALTER TABLE t1 RENAME TO t1_renamed;
+ALTER TABLE t2 RENAME TO t2_renamed;
+\d+ v1
+                             View "public.v1"
+ Column |  Type   | Collation | Nullable | Default | Storage | Description 
+--------+---------+-----------+----------+---------+---------+-------------
+ c1     | integer |           |          |         | plain   | 
+ c2     | integer |           |          |         | plain   | 
+ c3     | integer |           |          |         | plain   | 
+ c4     | integer |           |          |         | plain   | 
+View definition:
+ SELECT t1_renamed.c1_renamed AS c1,
+    t1_renamed.c2,
+    t2_renamed.c3_renamed AS c3,
+    t2_renamed.c4
+   FROM t1_renamed
+     JOIN t2_renamed FOR KEY (c3_renamed) -> t1_renamed (c1_renamed);
+
+SELECT * FROM v1; -- accepted
+ c1 | c2 | c3 | c4 
+----+----+----+----
+  1 | 10 |  1 | 10
+  3 | 30 |  3 | 30
+(2 rows)
+
+-- Undo the effect of the renames
+ALTER TABLE t2_renamed RENAME TO t2;
+ALTER TABLE t1_renamed RENAME TO t1;
+ALTER TABLE t2 RENAME COLUMN c3_renamed TO c3;
+ALTER TABLE t1 RENAME COLUMN c1_renamed TO c1;
+\d+ v1
+                             View "public.v1"
+ Column |  Type   | Collation | Nullable | Default | Storage | Description 
+--------+---------+-----------+----------+---------+---------+-------------
+ c1     | integer |           |          |         | plain   | 
+ c2     | integer |           |          |         | plain   | 
+ c3     | integer |           |          |         | plain   | 
+ c4     | integer |           |          |         | plain   | 
+View definition:
+ SELECT t1.c1,
+    t1.c2,
+    t2.c3,
+    t2.c4
+   FROM t1
+     JOIN t2 FOR KEY (c3) -> t1 (c1);
+
+-- Test so we didn't break the parser
+SELECT 1<-2; -- accepted, result: false
+ ?column? 
+----------
+ f
+(1 row)
+
+SELECT * FROM v1; -- accepted
+ c1 | c2 | c3 | c4 
+----+----+----+----
+  1 | 10 |  1 | 10
+  3 | 30 |  3 | 30
+(2 rows)
+
+SELECT * FROM t1 JOIN t2 FOR KEY (c3) -> t1 (c1); -- accepted
+ c1 | c2 | c3 | c4 
+----+----+----+----
+  1 | 10 |  1 | 10
+  3 | 30 |  3 | 30
+(2 rows)
+
+SELECT * FROM t1 JOIN t2 FOR KEY (c3) ->/*comment*/ t1 (c1); -- accepted
+ c1 | c2 | c3 | c4 
+----+----+----+----
+  1 | 10 |  1 | 10
+  3 | 30 |  3 | 30
+(2 rows)
+
+SELECT * FROM t1 JOIN t2 FOR KEY (c3) /*comment*/-> t1 (c1); -- accepted
+ c1 | c2 | c3 | c4 
+----+----+----+----
+  1 | 10 |  1 | 10
+  3 | 30 |  3 | 30
+(2 rows)
+
+SELECT * FROM t1 JOIN t2 FOR KEY (c3) /*comment*/->/*comment*/ t1 (c1); -- accepted
+ c1 | c2 | c3 | c4 
+----+----+----+----
+  1 | 10 |  1 | 10
+  3 | 30 |  3 | 30
+(2 rows)
+
+SELECT * FROM t1 JOIN t2 FOR KEY (c3) -> t2 (c1); -- rejected, reason: arrow alias must name the left operand
+ERROR:  key join alias "t2" is not present in the left join operand
+LINE 1: SELECT * FROM t1 JOIN t2 FOR KEY (c3) -> t2 (c1);
+                                 ^
+SELECT * FROM t1 JOIN t2 FOR KEY (c3) - > t1 (c2); -- rejected, reason: -> must be a single token
+ERROR:  syntax error at or near "-"
+LINE 1: SELECT * FROM t1 JOIN t2 FOR KEY (c3) - > t1 (c2);
+                                              ^
+SELECT * FROM t1 JOIN t2 FOR KEY (c3) -/*comment*/> t1 (c2); -- rejected, reason: comments cannot split the -> token
+ERROR:  syntax error at or near "-"
+LINE 1: SELECT * FROM t1 JOIN t2 FOR KEY (c3) -/*comment*/> t1 (c2);
+                                              ^
+SELECT * FROM t1 JOIN t2 FOR KEY (c3) -> t1 (c2); -- rejected, reason: referenced column is not the FK target key
+ERROR:  key join from referencing relation t2 to referenced relation t1 cannot be proven
+LINE 1: SELECT * FROM t1 JOIN t2 FOR KEY (c3) -> t1 (c2);
+                                 ^
+DETAIL:  Referenced columns t1 (c2) are not proven unique.
+SELECT * FROM t1 JOIN t2 FOR KEY (c4) -> t1 (c1); -- rejected, reason: referencing column is not backed by the FK
+ERROR:  key join from referencing relation t2 to referenced relation t1 cannot be proven
+LINE 1: SELECT * FROM t1 JOIN t2 FOR KEY (c4) -> t1 (c1);
+                                 ^
+DETAIL:  There is no matching foreign key constraint for t2 (c4) referencing t1 (c1).
+SELECT * FROM t1 JOIN t2 FOR KEY (c3,c4) -> t1 (c1,c2); -- rejected, reason: selected key does not match the FK arity
+ERROR:  key join from referencing relation t2 to referenced relation t1 cannot be proven
+LINE 1: SELECT * FROM t1 JOIN t2 FOR KEY (c3,c4) -> t1 (c1,c2);
+                                 ^
+DETAIL:  There is no matching foreign key constraint for t2 (c3, c4) referencing t1 (c1, c2).
+SELECT * FROM t1 JOIN t2 FOR KEY (c3) <- t1 (c1); -- rejected, reason: wrong direction for this table order
+ERROR:  key join from referencing relation t1 to referenced relation t2 cannot be proven
+LINE 1: SELECT * FROM t1 JOIN t2 FOR KEY (c3) <- t1 (c1);
+                                 ^
+DETAIL:  There is no matching foreign key constraint for t1 (c1) referencing t2 (c3).
+SELECT * FROM t1 JOIN t2 FOR KEY (c1) <- t1 (c3); -- rejected, reason: wrong direction and wrong referencing column
+ERROR:  referenced column "c1" does not exist
+LINE 1: SELECT * FROM t1 JOIN t2 FOR KEY (c1) <- t1 (c3);
+                                 ^
+SELECT * FROM t1 JOIN t2 FOR KEY (c3) <- t1 (c2); -- rejected, reason: wrong direction and wrong referenced column
+ERROR:  key join from referencing relation t1 to referenced relation t2 cannot be proven
+LINE 1: SELECT * FROM t1 JOIN t2 FOR KEY (c3) <- t1 (c2);
+                                 ^
+DETAIL:  There is no matching foreign key constraint for t1 (c2) referencing t2 (c3).
+SELECT * FROM t1 JOIN t2 FOR KEY (c4) <- t1 (c1); -- rejected, reason: wrong direction and wrong referencing column
+ERROR:  key join from referencing relation t1 to referenced relation t2 cannot be proven
+LINE 1: SELECT * FROM t1 JOIN t2 FOR KEY (c4) <- t1 (c1);
+                                 ^
+DETAIL:  There is no matching foreign key constraint for t1 (c1) referencing t2 (c4).
+SELECT * FROM t1 JOIN t2 FOR KEY (c3,c4) <- t1 (c1,c2); -- rejected, reason: wrong direction for the composite key
+ERROR:  key join from referencing relation t1 to referenced relation t2 cannot be proven
+LINE 1: SELECT * FROM t1 JOIN t2 FOR KEY (c3,c4) <- t1 (c1,c2);
+                                 ^
+DETAIL:  There is no matching foreign key constraint for t1 (c1, c2) referencing t2 (c3, c4).
+SELECT * FROM t1 AS a JOIN t2 AS b FOR KEY (c3) -> a (c2); -- rejected, reason: alias resolves to a non-referenced key column
+ERROR:  key join from referencing relation b to referenced relation a cannot be proven
+LINE 1: SELECT * FROM t1 AS a JOIN t2 AS b FOR KEY (c3) -> a (c2);
+                                           ^
+DETAIL:  Referenced columns a (c2) are not proven unique.
+SELECT * FROM t2 JOIN t1 FOR KEY (c1) <- t2 (c3); -- accepted
+ c3 | c4 | c1 | c2 
+----+----+----+----
+  1 | 10 |  1 | 10
+  3 | 30 |  3 | 30
+(2 rows)
+
+SELECT * FROM t2 JOIN t1 FOR KEY (c1) <-/*comment*/ t2 (c3); -- accepted
+ c3 | c4 | c1 | c2 
+----+----+----+----
+  1 | 10 |  1 | 10
+  3 | 30 |  3 | 30
+(2 rows)
+
+SELECT * FROM t2 JOIN t1 FOR KEY (c1) /*comment*/<- t2 (c3); -- accepted
+ c3 | c4 | c1 | c2 
+----+----+----+----
+  1 | 10 |  1 | 10
+  3 | 30 |  3 | 30
+(2 rows)
+
+SELECT * FROM t2 JOIN t1 FOR KEY (c1) /*comment*/<-/*comment*/ t2 (c3); -- accepted
+ c3 | c4 | c1 | c2 
+----+----+----+----
+  1 | 10 |  1 | 10
+  3 | 30 |  3 | 30
+(2 rows)
+
+SELECT * FROM t2 JOIN t1 FOR KEY (c1) <- t1 (c3); -- rejected, reason: arrow alias must name the left operand
+ERROR:  key join alias "t1" is not present in the left join operand
+LINE 1: SELECT * FROM t2 JOIN t1 FOR KEY (c1) <- t1 (c3);
+                                 ^
+SELECT * FROM t2 JOIN t1 FOR KEY (c1) < - t2 (c3); -- accepted
+ c3 | c4 | c1 | c2 
+----+----+----+----
+  1 | 10 |  1 | 10
+  3 | 30 |  3 | 30
+(2 rows)
+
+SELECT * FROM t2 JOIN t1 FOR KEY (c1) </*comment*/- t2 (c3); -- accepted
+ c3 | c4 | c1 | c2 
+----+----+----+----
+  1 | 10 |  1 | 10
+  3 | 30 |  3 | 30
+(2 rows)
+
+SELECT * FROM t2 JOIN t1 FOR KEY (c1) <- t2 (c4); -- rejected, reason: referencing column is not backed by the FK
+ERROR:  key join from referencing relation t2 to referenced relation t1 cannot be proven
+LINE 1: SELECT * FROM t2 JOIN t1 FOR KEY (c1) <- t2 (c4);
+                                 ^
+DETAIL:  There is no matching foreign key constraint for t2 (c4) referencing t1 (c1).
+SELECT * FROM t2 JOIN t1 FOR KEY (c2) <- t2 (c3); -- rejected, reason: referenced column is not the FK target key
+ERROR:  key join from referencing relation t2 to referenced relation t1 cannot be proven
+LINE 1: SELECT * FROM t2 JOIN t1 FOR KEY (c2) <- t2 (c3);
+                                 ^
+DETAIL:  Referenced columns t1 (c2) are not proven unique.
+SELECT * FROM t2 JOIN t1 FOR KEY (c1,c2) <- t2 (c3,c4); -- rejected, reason: selected key does not match the FK arity
+ERROR:  key join from referencing relation t2 to referenced relation t1 cannot be proven
+LINE 1: SELECT * FROM t2 JOIN t1 FOR KEY (c1,c2) <- t2 (c3,c4);
+                                 ^
+DETAIL:  There is no matching foreign key constraint for t2 (c3, c4) referencing t1 (c1, c2).
+SELECT * FROM t2 JOIN t1 FOR KEY (c1) -> t2 (c3); -- rejected, reason: wrong direction for this table order
+ERROR:  key join from referencing relation t1 to referenced relation t2 cannot be proven
+LINE 1: SELECT * FROM t2 JOIN t1 FOR KEY (c1) -> t2 (c3);
+                                 ^
+DETAIL:  There is no matching foreign key constraint for t1 (c1) referencing t2 (c3).
+SELECT * FROM t2 JOIN t1 FOR KEY (c1) -> t2 (c4); -- rejected, reason: wrong direction and wrong referencing column
+ERROR:  key join from referencing relation t1 to referenced relation t2 cannot be proven
+LINE 1: SELECT * FROM t2 JOIN t1 FOR KEY (c1) -> t2 (c4);
+                                 ^
+DETAIL:  There is no matching foreign key constraint for t1 (c1) referencing t2 (c4).
+SELECT * FROM t2 JOIN t1 FOR KEY (c2) -> t2 (c3); -- rejected, reason: wrong direction and wrong referenced column
+ERROR:  key join from referencing relation t1 to referenced relation t2 cannot be proven
+LINE 1: SELECT * FROM t2 JOIN t1 FOR KEY (c2) -> t2 (c3);
+                                 ^
+DETAIL:  There is no matching foreign key constraint for t1 (c2) referencing t2 (c3).
+SELECT * FROM t2 JOIN t1 FOR KEY (c1,c2) -> t2 (c3,c4); -- rejected, reason: wrong direction for the composite key
+ERROR:  key join from referencing relation t1 to referenced relation t2 cannot be proven
+LINE 1: SELECT * FROM t2 JOIN t1 FOR KEY (c1,c2) -> t2 (c3,c4);
+                                 ^
+DETAIL:  There is no matching foreign key constraint for t1 (c1, c2) referencing t2 (c3, c4).
+SELECT * FROM t2 AS a JOIN t1 AS b FOR KEY (c1) <- a (c4); -- rejected, reason: alias resolves to a non-referencing FK column
+ERROR:  key join from referencing relation a to referenced relation b cannot be proven
+LINE 1: SELECT * FROM t2 AS a JOIN t1 AS b FOR KEY (c1) <- a (c4);
+                                           ^
+DETAIL:  There is no matching foreign key constraint for a (c4) referencing b (c1).
+-- Self-join elimination must update stored key-join RT indexes when the
+-- duplicate relation is the referencing side.
+SET enable_self_join_elimination = on;
+SELECT c1.c3 AS child_key, p.c1 AS parent_key
+FROM t1 p
+JOIN t2 c1 FOR KEY (c3) -> p (c1)
+JOIN t2 c2 ON c2.c3 = c1.c3
+ORDER BY c1.c3;
+ child_key | parent_key 
+-----------+------------
+         1 |          1
+         3 |          3
+(2 rows)
+
+RESET enable_self_join_elimination;
+SELECT *
+FROM t1 p
+JOIN LATERAL (SELECT p.c1 AS c1) q
+  FOR KEY (c1) -> p (c1); -- rejected, reason: lateral outer Vars are not direct key facts
+ERROR:  key join from referencing relation q to referenced relation p cannot be proven
+LINE 4:   FOR KEY (c1) -> p (c1);
+          ^
+DETAIL:  There is no matching foreign key constraint for q (c1) referencing p (c1).
+SELECT *
+FROM t1 outerp
+JOIN LATERAL (
+    SELECT *
+    FROM t1 p
+    JOIN t2 c2
+        FOR KEY (c3) -> p (c1) FILTER (WHERE outerp.c1 = 1)
+    JOIN t2 c3
+        FOR KEY (c3) -> p (c1)
+) q ON true; -- rejected, reason: lateral filter outer Vars are not direct key facts
+ERROR:  key join from referencing relation c3 to referenced relation p cannot be proven
+LINE 9:         FOR KEY (c3) -> p (c1)
+                ^
+DETAIL:  Not every c3 (c3) value can be proven to have a matching p row. Referenced relation p may lose rows before this key join because of a preceding join.
+SELECT *
+FROM pg_catalog.pg_class pc
+-- rejected, reason: catalog NOT NULL columns do not provide proof facts
+JOIN t2 FOR KEY (c3) -> pc (relname);
+ERROR:  key join from referencing relation t2 to referenced relation pc cannot be proven
+LINE 4: JOIN t2 FOR KEY (c3) -> pc (relname);
+                ^
+DETAIL:  Referenced columns pc (relname) are not proven unique.
+CREATE TABLE no_key_join_fact_parent (id int);
+CREATE TABLE no_key_join_fact_child (parent_id int);
+SELECT *
+FROM ONLY no_key_join_fact_parent p
+JOIN ONLY no_key_join_fact_child c
+  FOR KEY (parent_id) -> p (id); -- rejected, reason: ONLY scans have no proof facts here
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 4:   FOR KEY (parent_id) -> p (id);
+          ^
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+DROP TABLE no_key_join_fact_child, no_key_join_fact_parent;
+CREATE TABLE invalid_unique_parent (id int);
+CREATE TABLE invalid_unique_child (parent_id int);
+INSERT INTO invalid_unique_parent VALUES (1), (1);
+INSERT INTO invalid_unique_child VALUES (1);
+CREATE UNIQUE INDEX CONCURRENTLY invalid_unique_parent_id_idx
+    ON invalid_unique_parent (id);
+ERROR:  could not create unique index "invalid_unique_parent_id_idx"
+DETAIL:  Key (id)=(1) is duplicated.
+SELECT *
+FROM invalid_unique_parent p
+-- rejected, reason: invalid unique indexes do not provide proof facts
+JOIN invalid_unique_child c FOR KEY (parent_id) -> p (id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 4: JOIN invalid_unique_child c FOR KEY (parent_id) -> p (id);
+                                    ^
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+DROP TABLE invalid_unique_child, invalid_unique_parent;
+CREATE TABLE expr_unique_parent (id int);
+CREATE TABLE expr_unique_child (parent_id int);
+CREATE UNIQUE INDEX expr_unique_parent_idx ON expr_unique_parent ((id + 0));
+INSERT INTO expr_unique_parent VALUES (1);
+INSERT INTO expr_unique_child VALUES (1);
+SELECT *
+FROM expr_unique_parent p
+-- rejected, reason: expression indexes do not provide proof facts
+JOIN expr_unique_child c FOR KEY (parent_id) -> p (id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 4: JOIN expr_unique_child c FOR KEY (parent_id) -> p (id);
+                                 ^
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+DROP TABLE expr_unique_child, expr_unique_parent;
+CREATE TABLE partial_unique_parent (id int);
+CREATE TABLE partial_unique_child (parent_id int);
+CREATE UNIQUE INDEX partial_unique_parent_idx ON partial_unique_parent (id)
+    WHERE id > 0;
+INSERT INTO partial_unique_parent VALUES (1);
+INSERT INTO partial_unique_child VALUES (1);
+SELECT *
+FROM partial_unique_parent p
+-- rejected, reason: partial indexes do not provide proof facts
+JOIN partial_unique_child c FOR KEY (parent_id) -> p (id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 4: JOIN partial_unique_child c FOR KEY (parent_id) -> p (id);
+                                    ^
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+DROP TABLE partial_unique_child, partial_unique_parent;
+CREATE TABLE collate_unique_parent (id text COLLATE "C");
+CREATE TABLE collate_unique_child (parent_id text COLLATE "C");
+CREATE UNIQUE INDEX collate_unique_parent_idx
+    ON collate_unique_parent (id COLLATE "POSIX");
+INSERT INTO collate_unique_parent VALUES ('1');
+INSERT INTO collate_unique_child VALUES ('1');
+SELECT *
+FROM collate_unique_parent p
+-- rejected, reason: mismatched index collations do not provide proof facts
+JOIN collate_unique_child c FOR KEY (parent_id) -> p (id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 4: JOIN collate_unique_child c FOR KEY (parent_id) -> p (id);
+                                    ^
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+DROP TABLE collate_unique_child, collate_unique_parent;
+CREATE OPERATOR CLASS noeq_unique_int4_ops FOR TYPE int USING btree AS
+    FUNCTION 1 btint4cmp(int, int);
+CREATE TABLE noeq_unique_parent (id int PRIMARY KEY);
+CREATE TABLE noeq_unique_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES noeq_unique_parent (id)
+);
+CREATE UNIQUE INDEX noeq_unique_parent_bad_idx
+    ON noeq_unique_parent USING btree (id noeq_unique_int4_ops);
+INSERT INTO noeq_unique_parent VALUES (1);
+INSERT INTO noeq_unique_child VALUES (10, 1);
+PREPARE noeq_unique_plan AS
+SELECT *
+FROM noeq_unique_parent p
+-- accepted, reason: unique index without equality operator is skipped
+JOIN noeq_unique_child c FOR KEY (parent_id) -> p (id);
+DEALLOCATE noeq_unique_plan;
+DROP TABLE noeq_unique_child, noeq_unique_parent;
+DROP OPERATOR CLASS noeq_unique_int4_ops USING btree;
+DROP OPERATOR FAMILY noeq_unique_int4_ops USING btree;
+CREATE DOMAIN domain_key_join_int AS int;
+CREATE TABLE domain_key_join_parent (id domain_key_join_int PRIMARY KEY);
+CREATE TABLE domain_key_join_child
+(
+    id int PRIMARY KEY,
+    parent_id domain_key_join_int NOT NULL REFERENCES domain_key_join_parent (id)
+);
+INSERT INTO domain_key_join_parent VALUES (1);
+INSERT INTO domain_key_join_child VALUES (1, 1);
+SELECT *
+FROM domain_key_join_parent p
+-- accepted, reason: FK and PK expose the same domain type
+JOIN domain_key_join_child c FOR KEY (parent_id) -> p (id);
+ id | id | parent_id 
+----+----+-----------
+  1 |  1 |         1
+(1 row)
+
+DROP TABLE domain_key_join_child, domain_key_join_parent;
+CREATE TABLE domain_key_join_base_parent (id int PRIMARY KEY);
+CREATE TABLE domain_key_join_domain_child
+(
+    id int PRIMARY KEY,
+    parent_id domain_key_join_int NOT NULL
+        REFERENCES domain_key_join_base_parent (id)
+);
+INSERT INTO domain_key_join_base_parent VALUES (1);
+INSERT INTO domain_key_join_domain_child VALUES (1, 1);
+SELECT *
+FROM domain_key_join_base_parent p
+-- rejected, reason: domain FK and base PK do not expose the same type
+JOIN domain_key_join_domain_child c FOR KEY (parent_id) -> p (id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 4: JOIN domain_key_join_domain_child c FOR KEY (parent_id) -> p...
+                                            ^
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+DROP TABLE domain_key_join_domain_child, domain_key_join_base_parent;
+CREATE TABLE domain_key_join_domain_parent (id domain_key_join_int PRIMARY KEY);
+CREATE TABLE domain_key_join_base_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL
+        REFERENCES domain_key_join_domain_parent (id)
+);
+INSERT INTO domain_key_join_domain_parent VALUES (1);
+INSERT INTO domain_key_join_base_child VALUES (1, 1);
+SELECT *
+FROM domain_key_join_domain_parent p
+-- rejected, reason: base FK and domain PK do not expose the same type
+JOIN domain_key_join_base_child c FOR KEY (parent_id) -> p (id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 4: JOIN domain_key_join_base_child c FOR KEY (parent_id) -> p (...
+                                          ^
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+DROP TABLE domain_key_join_base_child, domain_key_join_domain_parent;
+DROP DOMAIN domain_key_join_int;
+CREATE TABLE key_join_self_syntax
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES key_join_self_syntax (id)
+);
+SELECT * FROM key_join_self_syntax AS t1 JOIN key_join_self_syntax AS t2 FOR KEY (parent_id) -> t1 (id); -- accepted
+ id | parent_id | id | parent_id 
+----+-----------+----+-----------
+(0 rows)
+
+SELECT * FROM key_join_self_syntax AS t1 JOIN key_join_self_syntax AS t2 FOR KEY (parent_id) -> t2 (id); -- rejected, reason: arrow alias must name the left operand
+ERROR:  key join alias "t2" is not present in the left join operand
+LINE 1: ...self_syntax AS t1 JOIN key_join_self_syntax AS t2 FOR KEY (p...
+                                                             ^
+SELECT * FROM key_join_self_syntax AS t1 JOIN key_join_self_syntax AS t2 FOR KEY (id) <- t1 (parent_id); -- accepted
+ id | parent_id | id | parent_id 
+----+-----------+----+-----------
+(0 rows)
+
+SELECT * FROM key_join_self_syntax AS t1 JOIN key_join_self_syntax AS t2 FOR KEY (id) <- t2 (parent_id); -- rejected, reason: arrow alias must name the left operand
+ERROR:  key join alias "t2" is not present in the left join operand
+LINE 1: ...self_syntax AS t1 JOIN key_join_self_syntax AS t2 FOR KEY (i...
+                                                             ^
+DROP TABLE key_join_self_syntax;
+ALTER TABLE t2 DROP CONSTRAINT t2_c3_fkey; -- rejected, reason: stored view depends on the FK proof
+ERROR:  cannot drop constraint t2_c3_fkey on table t2 because other objects depend on it
+DETAIL:  view v1 depends on constraint t2_c3_fkey on table t2
+HINT:  Use DROP ... CASCADE to drop the dependent objects too.
+DROP VIEW v1;
+ALTER TABLE t2 DROP CONSTRAINT t2_c3_fkey;
+/* Recreate contraint and view to test DROP CASCADE */
+ALTER TABLE t2 ADD CONSTRAINT t2_c3_fkey FOREIGN KEY (c3) REFERENCES t1 (c1);
+CREATE VIEW v1 AS
+SELECT *
+FROM t1
+-- accepted
+JOIN t2 FOR KEY (c3) -> t1 (c1);
+ALTER TABLE t2 DROP CONSTRAINT t2_c3_fkey CASCADE; -- accepted
+NOTICE:  drop cascades to view v1
+SELECT * FROM t1 JOIN t2 FOR KEY (c3) -> t1 (c1); -- rejected, reason: FK constraint was dropped
+ERROR:  key join from referencing relation t2 to referenced relation t1 cannot be proven
+LINE 1: SELECT * FROM t1 JOIN t2 FOR KEY (c3) -> t1 (c1);
+                                 ^
+DETAIL:  There is no matching foreign key constraint for t2 (c3) referencing t1 (c1).
+SELECT * FROM t2 JOIN t1 FOR KEY (c1) <- t2 (c3); -- rejected, reason: FK constraint was dropped
+ERROR:  key join from referencing relation t2 to referenced relation t1 cannot be proven
+LINE 1: SELECT * FROM t2 JOIN t1 FOR KEY (c1) <- t2 (c3);
+                                 ^
+DETAIL:  There is no matching foreign key constraint for t2 (c3) referencing t1 (c1).
+ALTER TABLE t1 ADD UNIQUE (c1,c2);
+ALTER TABLE t2 ADD CONSTRAINT t2_c3_c4_fkey FOREIGN KEY (c3,c4) REFERENCES t1 (c1,c2);
+CREATE VIEW v2 AS
+SELECT * FROM t1 JOIN t2 FOR KEY (c3,c4) -> t1 (c1,c2); -- accepted
+SELECT * FROM t1 JOIN t2 FOR KEY (c3,c4) -> t1 (c1,c2); -- accepted
+ c1 | c2 | c3 | c4 
+----+----+----+----
+  1 | 10 |  1 | 10
+  3 | 30 |  3 | 30
+(2 rows)
+
+SELECT * FROM v2; -- accepted
+ c1 | c2 | c3 | c4 
+----+----+----+----
+  1 | 10 |  1 | 10
+  3 | 30 |  3 | 30
+(2 rows)
+
+\d+ v2
+                             View "public.v2"
+ Column |  Type   | Collation | Nullable | Default | Storage | Description 
+--------+---------+-----------+----------+---------+---------+-------------
+ c1     | integer |           |          |         | plain   | 
+ c2     | integer |           |          |         | plain   | 
+ c3     | integer |           |          |         | plain   | 
+ c4     | integer |           |          |         | plain   | 
+View definition:
+ SELECT t1.c1,
+    t1.c2,
+    t2.c3,
+    t2.c4
+   FROM t1
+     JOIN t2 FOR KEY (c3, c4) -> t1 (c1, c2);
+
+CREATE VIEW v3 AS
+SELECT * FROM t2 JOIN t1 FOR KEY (c1,c2) <- t2 (c3,c4); -- accepted
+SELECT * FROM t2 JOIN t1 FOR KEY (c1,c2) <- t2 (c3,c4); -- accepted
+ c3 | c4 | c1 | c2 
+----+----+----+----
+  1 | 10 |  1 | 10
+  3 | 30 |  3 | 30
+(2 rows)
+
+\d+ v3
+                             View "public.v3"
+ Column |  Type   | Collation | Nullable | Default | Storage | Description 
+--------+---------+-----------+----------+---------+---------+-------------
+ c3     | integer |           |          |         | plain   | 
+ c4     | integer |           |          |         | plain   | 
+ c1     | integer |           |          |         | plain   | 
+ c2     | integer |           |          |         | plain   | 
+View definition:
+ SELECT t2.c3,
+    t2.c4,
+    t1.c1,
+    t1.c2
+   FROM t2
+     JOIN t1 FOR KEY (c1, c2) <- t2 (c3, c4);
+
+SELECT * FROM v3; -- accepted
+ c3 | c4 | c1 | c2 
+----+----+----+----
+  1 | 10 |  1 | 10
+  3 | 30 |  3 | 30
+(2 rows)
+
+SELECT * FROM t1 JOIN t2 FOR KEY (c3) -> t1 (c1); -- rejected, reason: single-column FK proof is no longer available
+ERROR:  key join from referencing relation t2 to referenced relation t1 cannot be proven
+LINE 1: SELECT * FROM t1 JOIN t2 FOR KEY (c3) -> t1 (c1);
+                                 ^
+DETAIL:  There is no matching foreign key constraint for t2 (c3) referencing t1 (c1).
+SELECT * FROM t2 JOIN t1 FOR KEY (c1) <- t2 (c3); -- rejected, reason: single-column FK proof is no longer available
+ERROR:  key join from referencing relation t2 to referenced relation t1 cannot be proven
+LINE 1: SELECT * FROM t2 JOIN t1 FOR KEY (c1) <- t2 (c3);
+                                 ^
+DETAIL:  There is no matching foreign key constraint for t2 (c3) referencing t1 (c1).
+SELECT * FROM t1 JOIN t2 FOR KEY (c3,c4) <- t1 (c1,c2); -- rejected, reason: wrong direction for the composite FK
+ERROR:  key join from referencing relation t1 to referenced relation t2 cannot be proven
+LINE 1: SELECT * FROM t1 JOIN t2 FOR KEY (c3,c4) <- t1 (c1,c2);
+                                 ^
+DETAIL:  There is no matching foreign key constraint for t1 (c1, c2) referencing t2 (c3, c4).
+SELECT * FROM t2 JOIN t1 FOR KEY (c1,c2) -> t2 (c3,c4); -- rejected, reason: wrong direction for the composite FK
+ERROR:  key join from referencing relation t1 to referenced relation t2 cannot be proven
+LINE 1: SELECT * FROM t2 JOIN t1 FOR KEY (c1,c2) -> t2 (c3,c4);
+                                 ^
+DETAIL:  There is no matching foreign key constraint for t1 (c1, c2) referencing t2 (c3, c4).
+--
+-- Referenced-partition FK clone rows do not prove containment in one partition
+--
+CREATE TABLE kj_part_parent (id int PRIMARY KEY) PARTITION BY RANGE (id);
+CREATE TABLE kj_part_parent_1 PARTITION OF kj_part_parent
+    FOR VALUES FROM (1) TO (10);
+CREATE TABLE kj_part_parent_2 PARTITION OF kj_part_parent
+    FOR VALUES FROM (10) TO (20);
+CREATE TABLE kj_part_child
+(
+    id  int PRIMARY KEY,
+    pid int NOT NULL REFERENCES kj_part_parent (id)
+);
+INSERT INTO kj_part_parent VALUES (1), (11);
+INSERT INTO kj_part_child VALUES (101, 1), (111, 11);
+SELECT c.id AS child_id, c.pid, p.id AS parent_id
+FROM kj_part_parent p
+-- accepted, reason: parent FK proves containment in the whole partitioned table
+JOIN kj_part_child c FOR KEY (pid) -> p (id)
+ORDER BY c.id;
+ child_id | pid | parent_id 
+----------+-----+-----------
+      101 |   1 |         1
+      111 |  11 |        11
+(2 rows)
+
+SELECT c.id AS child_id, c.pid, p1.id AS parent_id
+FROM kj_part_parent_1 p1
+-- rejected, reason: a cloned FK to one referenced partition is not a global containment proof
+JOIN kj_part_child c FOR KEY (pid) -> p1 (id)
+ORDER BY c.id;
+ERROR:  key join from referencing relation c to referenced relation p1 cannot be proven
+LINE 4: JOIN kj_part_child c FOR KEY (pid) -> p1 (id)
+                             ^
+DETAIL:  Referenced columns p1 (id) are not proven unique.
+CREATE VIEW kj_part_bad_view AS
+SELECT c.id AS child_id, c.pid, p1.id AS parent_id
+FROM kj_part_parent_1 p1
+-- rejected, reason: invalid referenced-partition FK proof cannot be stored
+JOIN kj_part_child c FOR KEY (pid) -> p1 (id);
+ERROR:  key join from referencing relation c to referenced relation p1 cannot be proven
+LINE 5: JOIN kj_part_child c FOR KEY (pid) -> p1 (id);
+                             ^
+DETAIL:  Referenced columns p1 (id) are not proven unique.
+CREATE TABLE kj_part_ref_parent (id int PRIMARY KEY);
+CREATE TABLE kj_part_ref_child
+(
+    id  int,
+    pid int NOT NULL REFERENCES kj_part_ref_parent (id)
+) PARTITION BY RANGE (id);
+CREATE TABLE kj_part_ref_child_1 PARTITION OF kj_part_ref_child
+    FOR VALUES FROM (1) TO (10);
+CREATE TABLE kj_part_ref_child_sub PARTITION OF kj_part_ref_child
+    FOR VALUES FROM (10) TO (20) PARTITION BY RANGE (id);
+CREATE TABLE kj_part_ref_child_sub_1 PARTITION OF kj_part_ref_child_sub
+    FOR VALUES FROM (10) TO (15);
+INSERT INTO kj_part_ref_parent VALUES (1), (11);
+INSERT INTO kj_part_ref_child_1 VALUES (1, 1);
+INSERT INTO kj_part_ref_child_sub_1 VALUES (11, 11);
+SELECT c.id AS child_id, c.pid, p.id AS parent_id
+FROM kj_part_ref_parent p
+-- accepted, reason: referencing-side partition FK still targets the same referenced keyspace
+JOIN kj_part_ref_child_1 c FOR KEY (pid) -> p (id);
+ child_id | pid | parent_id 
+----------+-----+-----------
+        1 |   1 |         1
+(1 row)
+
+SELECT c.id AS child_id, c.pid, p.id AS parent_id
+FROM kj_part_ref_parent p
+-- accepted, reason: multi-level referencing partition FK still targets the same referenced keyspace
+JOIN kj_part_ref_child_sub_1 c FOR KEY (pid) -> p (id);
+ child_id | pid | parent_id 
+----------+-----+-----------
+       11 |  11 |        11
+(1 row)
+
+DROP TABLE kj_part_ref_child, kj_part_ref_parent;
+DROP TABLE kj_part_child, kj_part_parent;
+--
+-- Test nulls and multiple tables
+--
+CREATE TABLE t3
+(
+    c5 int,
+    c6 int,
+    CONSTRAINT t3_c5_c6_fkey FOREIGN KEY (c5, c6) REFERENCES t1 (c1, c2)
+);
+INSERT INTO t3 (c5, c6) VALUES (1, 10); -- accepted
+INSERT INTO t3 (c5, c6) VALUES (3, 30); -- accepted
+INSERT INTO t3 (c5, c6) VALUES (3, NULL); -- accepted
+INSERT INTO t3 (c5, c6) VALUES (NULL, 30); -- accepted
+INSERT INTO t3 (c5, c6) VALUES (1234, NULL); -- accepted
+INSERT INTO t3 (c5, c6) VALUES (NULL, 5678); -- accepted
+INSERT INTO t3 (c5, c6) VALUES (NULL, NULL); -- accepted
+--
+-- Test composite key joins with columns in matching order
+--
+SELECT *
+FROM t1
+-- rejected, reason: nullable referencing columns are not preserved by an inner key join
+JOIN t2 FOR KEY (c3,c4) -> t1 (c1,c2)
+JOIN t3 FOR KEY (c5,c6) -> t1 (c1,c2);
+ERROR:  key join from referencing relation t3 to referenced relation t1 cannot be proven
+LINE 5: JOIN t3 FOR KEY (c5,c6) -> t1 (c1,c2);
+                ^
+DETAIL:  Not every t3 (c5, c6) value can be proven to have a matching t1 row. Referenced relation t1 may lose rows before this key join because of a preceding join.
+SELECT *
+FROM t1
+-- rejected, reason: nullable referencing columns are on the non-preserved side
+JOIN t2 FOR KEY (c3,c4) -> t1 (c1,c2)
+LEFT JOIN t3 FOR KEY (c5,c6) -> t1 (c1,c2);
+ERROR:  key join from referencing relation t3 to referenced relation t1 cannot be proven
+LINE 5: LEFT JOIN t3 FOR KEY (c5,c6) -> t1 (c1,c2);
+                     ^
+DETAIL:  Not every t3 (c5, c6) value can be proven to have a matching t1 row. Referenced relation t1 may lose rows before this key join because of a preceding join.
+SELECT *
+FROM t1
+-- rejected, reason: referenced t1 surface is unique but not row-covered after the preceding inner key join
+JOIN t2 FOR KEY (c3,c4) -> t1 (c1,c2)
+RIGHT JOIN t3 FOR KEY (c5,c6) -> t1 (c1,c2);
+ERROR:  key join from referencing relation t3 to referenced relation t1 cannot be proven
+LINE 5: RIGHT JOIN t3 FOR KEY (c5,c6) -> t1 (c1,c2);
+                      ^
+DETAIL:  Not every t3 (c5, c6) value can be proven to have a matching t1 row. Referenced relation t1 may lose rows before this key join because of a preceding join.
+--
+-- Test composite key joins with swapped column orders
+--
+SELECT *
+FROM t1
+-- rejected, reason: nullable referencing columns are not preserved by an inner key join
+JOIN t2 FOR KEY (c4,c3) -> t1 (c2,c1)
+JOIN t3 FOR KEY (c6,c5) -> t1 (c2,c1);
+ERROR:  key join from referencing relation t3 to referenced relation t1 cannot be proven
+LINE 5: JOIN t3 FOR KEY (c6,c5) -> t1 (c2,c1);
+                ^
+DETAIL:  Not every t3 (c6, c5) value can be proven to have a matching t1 row. Referenced relation t1 may lose rows before this key join because of a preceding join.
+--
+-- Test mismatched column orders between referencing and referenced sides
+--
+SELECT *
+FROM t1
+-- accepted
+JOIN t2 FOR KEY (c4,c3) -> t1 (c2,c1)
+JOIN t3 FOR KEY (c6,c5) -> t1 (c1,c2); -- rejected, reason: referencing and referenced column orders do not match the FK
+ERROR:  key join from referencing relation t3 to referenced relation t1 cannot be proven
+LINE 5: JOIN t3 FOR KEY (c6,c5) -> t1 (c1,c2);
+                ^
+DETAIL:  Not every t3 (c6, c5) value can be proven to have a matching t1 row. Referenced relation t1 may lose rows before this key join because of a preceding join.
+--
+-- Test defining foreign key constraints with MATCH FULL
+--
+CREATE TABLE t4
+(
+    c7 int,
+    c8 int,
+    CONSTRAINT t4_c7_c8_fkey FOREIGN KEY (c7, c8) REFERENCES t1 (c1, c2) MATCH FULL
+);
+INSERT INTO t4 (c7, c8) VALUES (1, 10); -- accepted
+INSERT INTO t4 (c7, c8) VALUES (3, 30); -- accepted
+INSERT INTO t4 (c7, c8) VALUES (3, NULL); -- rejected, reason: MATCH FULL disallows partially NULL composite FK values
+ERROR:  insert or update on table "t4" violates foreign key constraint "t4_c7_c8_fkey"
+DETAIL:  MATCH FULL does not allow mixing of null and nonnull key values.
+INSERT INTO t4 (c7, c8) VALUES (NULL, 30); -- rejected, reason: MATCH FULL disallows partially NULL composite FK values
+ERROR:  insert or update on table "t4" violates foreign key constraint "t4_c7_c8_fkey"
+DETAIL:  MATCH FULL does not allow mixing of null and nonnull key values.
+INSERT INTO t4 (c7, c8) VALUES (1234, NULL); -- rejected, reason: MATCH FULL disallows partially NULL composite FK values
+ERROR:  insert or update on table "t4" violates foreign key constraint "t4_c7_c8_fkey"
+DETAIL:  MATCH FULL does not allow mixing of null and nonnull key values.
+INSERT INTO t4 (c7, c8) VALUES (NULL, 5678); -- rejected, reason: MATCH FULL disallows partially NULL composite FK values
+ERROR:  insert or update on table "t4" violates foreign key constraint "t4_c7_c8_fkey"
+DETAIL:  MATCH FULL does not allow mixing of null and nonnull key values.
+INSERT INTO t4 (c7, c8) VALUES (NULL, NULL); -- accepted
+SELECT *
+FROM t1
+-- rejected, reason: nullable referencing columns are not preserved by an inner key join
+JOIN t2 FOR KEY (c3,c4) -> t1 (c1,c2)
+JOIN t4 FOR KEY (c7,c8) -> t1 (c1,c2);
+ERROR:  key join from referencing relation t4 to referenced relation t1 cannot be proven
+LINE 5: JOIN t4 FOR KEY (c7,c8) -> t1 (c1,c2);
+                ^
+DETAIL:  Not every t4 (c7, c8) value can be proven to have a matching t1 row. Referenced relation t1 may lose rows before this key join because of a preceding join.
+SELECT *
+FROM t1
+-- rejected, reason: nullable referencing columns are on the non-preserved side
+JOIN t2 FOR KEY (c3,c4) -> t1 (c1,c2)
+LEFT JOIN t4 FOR KEY (c7,c8) -> t1 (c1,c2);
+ERROR:  key join from referencing relation t4 to referenced relation t1 cannot be proven
+LINE 5: LEFT JOIN t4 FOR KEY (c7,c8) -> t1 (c1,c2);
+                     ^
+DETAIL:  Not every t4 (c7, c8) value can be proven to have a matching t1 row. Referenced relation t1 may lose rows before this key join because of a preceding join.
+SELECT *
+FROM t1
+-- rejected, reason: referenced t1 surface is unique but not row-covered after the preceding inner key join
+JOIN t2 FOR KEY (c3,c4) -> t1 (c1,c2)
+RIGHT JOIN t4 FOR KEY (c7,c8) -> t1 (c1,c2);
+ERROR:  key join from referencing relation t4 to referenced relation t1 cannot be proven
+LINE 5: RIGHT JOIN t4 FOR KEY (c7,c8) -> t1 (c1,c2);
+                      ^
+DETAIL:  Not every t4 (c7, c8) value can be proven to have a matching t1 row. Referenced relation t1 may lose rows before this key join because of a preceding join.
+-- Recreate stuff for pg_dump tests
+ALTER TABLE t2
+    ADD CONSTRAINT t2_c3_fkey FOREIGN KEY (c3) REFERENCES t1 (c1);
+CREATE VIEW v1 AS
+SELECT *
+FROM t1
+-- accepted
+JOIN t2 FOR KEY (c3) -> t1 (c1);
+CREATE TABLE t5
+(
+    c9 int not null,
+    c10 int not null,
+    c11 int not null,
+    c12 int not null,
+    CONSTRAINT t5_pkey PRIMARY KEY (c9, c10),
+    CONSTRAINT t5_c11_c12_fkey FOREIGN KEY (c11, c12) REFERENCES t1 (c1, c2)
+);
+INSERT INTO t5 (c9, c10, c11, c12) VALUES (1, 2, 1, 10);
+INSERT INTO t5 (c9, c10, c11, c12) VALUES (3, 4, 3, 30);
+CREATE TABLE t6
+(
+    c13 int not null,
+    c14 int not null,
+    CONSTRAINT t6_c13_c14_fkey FOREIGN KEY (c13, c14) REFERENCES t5 (c9, c10)
+);
+INSERT INTO t6 (c13, c14) VALUES (1, 2);
+INSERT INTO t6 (c13, c14) VALUES (3, 4);
+INSERT INTO t6 (c13, c14) VALUES (3, 4);
+CREATE TABLE t7
+(
+    c15 int not null,
+    c16 int not null,
+    CONSTRAINT t7_c15_c16_fkey FOREIGN KEY (c15, c16) REFERENCES t5 (c9, c10)
+);
+INSERT INTO t7 (c15, c16) VALUES (1, 2);
+INSERT INTO t7 (c15, c16) VALUES (1, 2);
+INSERT INTO t7 (c15, c16) VALUES (3, 4);
+CREATE TABLE t8
+(
+    c17 int not null,
+    c18 int not null,
+    c19 int,
+    c20 int,
+    CONSTRAINT t8_pkey PRIMARY KEY (c17, c18),
+    CONSTRAINT t8_c19_c20_fkey FOREIGN KEY (c19, c20) REFERENCES t1 (c1, c2)
+);
+INSERT INTO t8 (c17, c18, c19, c20) VALUES (1, 2, 1, 10);
+INSERT INTO t8 (c17, c18, c19, c20) VALUES (3, 4, 3, 30);
+CREATE TABLE t9
+(
+    c21 int not null,
+    c22 int not null,
+    CONSTRAINT t9_c21_c22_fkey FOREIGN KEY (c21, c22) REFERENCES t8 (c17, c18)
+);
+INSERT INTO t9 (c21, c22) VALUES (1, 2);
+INSERT INTO t9 (c21, c22) VALUES (3, 4);
+INSERT INTO t9 (c21, c22) VALUES (3, 4);
+CREATE TABLE t10
+(
+    c23 INT NOT NULL,
+    c24 INT NOT NULL,
+    c25 INT NOT NULL,
+    c26 INT NOT NULL,
+    CONSTRAINT t10_pkey PRIMARY KEY (c23, c24),
+    CONSTRAINT t10_c23_c24_fkey FOREIGN KEY (c23, c24) REFERENCES t1 (c1, c2),
+    CONSTRAINT t10_c25_c26_fkey FOREIGN KEY (c25, c26) REFERENCES t10 (c23, c24)
+);
+INSERT INTO t10 (c23, c24, c25, c26) VALUES (1, 10, 1, 10);
+CREATE TABLE t11
+(
+    c27 INT NOT NULL,
+    c28 INT NOT NULL,
+    CONSTRAINT t11_pkey PRIMARY KEY (c27, c28),
+    CONSTRAINT t11_c27_c28_fkey FOREIGN KEY (c27, c28) REFERENCES t10 (c23, c24)
+);
+INSERT INTO t11 (c27, c28) VALUES (1, 10);
+--
+-- Test subqueries
+--
+SELECT
+    a.c1,
+    a.c2,
+    b.c3,
+    b.c4
+FROM t1 AS a
+JOIN
+(
+    SELECT * FROM t2
+-- accepted
+) AS b FOR KEY (c3) -> a (c1);
+ c1 | c2 | c3 | c4 
+----+----+----+----
+  1 | 10 |  1 | 10
+  3 | 30 |  3 | 30
+(2 rows)
+
+SELECT
+    a.c1,
+    a.c2,
+    b.c3,
+    b.c4
+FROM
+(
+    SELECT * FROM t1
+) AS a
+JOIN
+(
+    SELECT * FROM t2
+-- accepted
+) AS b FOR KEY (c3) -> a (c1);
+ c1 | c2 | c3 | c4 
+----+----+----+----
+  1 | 10 |  1 | 10
+  3 | 30 |  3 | 30
+(2 rows)
+
+SELECT
+    a.t1_c1,
+    a.t1_c2,
+    b.t2_c3,
+    b.t2_c4
+FROM
+(
+    SELECT c1 AS t1_c1, c2 AS t1_c2 FROM t1
+) AS a
+JOIN
+(
+    SELECT c3 AS t2_c3, c4 AS t2_c4 FROM t2
+-- accepted
+) AS b FOR KEY (t2_c3) -> a (t1_c1);
+ t1_c1 | t1_c2 | t2_c3 | t2_c4 
+-------+-------+-------+-------
+     1 |    10 |     1 |    10
+     3 |    30 |     3 |    30
+(2 rows)
+
+SELECT
+    a.outer_c1,
+    a.outer_c2,
+    b.outer_c3,
+    b.outer_c4
+FROM
+(
+    SELECT mid_c1 AS outer_c1, mid_c2 AS outer_c2 FROM
+    (
+        SELECT c1 AS mid_c1, c2 AS mid_c2 FROM t1
+    ) sub1
+) AS a
+JOIN
+(
+    SELECT mid_c3 AS outer_c3, mid_c4 AS outer_c4 FROM
+    (
+        SELECT c3 AS mid_c3, c4 AS mid_c4 FROM t2
+    ) sub2
+-- accepted
+) AS b FOR KEY (outer_c3) -> a (outer_c1);
+ outer_c1 | outer_c2 | outer_c3 | outer_c4 
+----------+----------+----------+----------
+        1 |       10 |        1 |       10
+        3 |       30 |        3 |       30
+(2 rows)
+
+SELECT *
+FROM t1
+JOIN
+(
+    SELECT
+        t10.c23,
+        t10.c24,
+        t10_2.c25,
+        t10_2.c26
+    FROM t10
+-- accepted
+    JOIN t10 AS t10_2 FOR KEY (c23, c24) <- t10 (c25, c26)
+) AS q1 FOR KEY (c23, c24) -> t1 (c1, c2);
+ c1 | c2 | c23 | c24 | c25 | c26 
+----+----+-----+-----+-----+-----
+  1 | 10 |   1 |  10 |   1 |  10
+(1 row)
+
+SELECT *
+FROM t1
+JOIN LATERAL (
+    SELECT c3, c4 FROM t2 WHERE c4 = c1 + 9
+-- rejected, reason: LATERAL subqueries do not expose surface FK facts
+) AS q1 FOR KEY (c3) -> t1 (c1);
+ERROR:  key join from referencing relation q1 to referenced relation t1 cannot be proven
+LINE 6: ) AS q1 FOR KEY (c3) -> t1 (c1);
+                ^
+DETAIL:  There is no matching foreign key constraint for q1 (c3) referencing t1 (c1).
+SELECT *
+FROM t1 AS outer_t,
+     LATERAL (SELECT outer_t.c1) AS q1(c1)
+-- rejected, reason: LATERAL subqueries do not expose referenced-side surface facts
+     JOIN t2 FOR KEY (c3) -> q1 (c1);
+ERROR:  key join from referencing relation t2 to referenced relation q1 cannot be proven
+LINE 5:      JOIN t2 FOR KEY (c3) -> q1 (c1);
+                     ^
+DETAIL:  There is no matching foreign key constraint for t2 (c3) referencing q1 (c1).
+SELECT *
+FROM t1 AS outer_t,
+     LATERAL (SELECT outer_t.c1, g FROM generate_series(1, 2) AS g) AS q1(c1, g)
+-- rejected, reason: LATERAL referenced side can duplicate inherited keys
+     JOIN t2 FOR KEY (c3) -> q1 (c1);
+ERROR:  key join from referencing relation t2 to referenced relation q1 cannot be proven
+LINE 5:      JOIN t2 FOR KEY (c3) -> q1 (c1);
+                     ^
+DETAIL:  There is no matching foreign key constraint for t2 (c3) referencing q1 (c1).
+SELECT *
+FROM t1 AS outer_t,
+     LATERAL (SELECT outer_t.c1 WHERE false) AS q1(c1)
+-- rejected, reason: LATERAL referenced side can suppress inherited keys
+     JOIN t2 FOR KEY (c3) -> q1 (c1);
+ERROR:  key join from referencing relation t2 to referenced relation q1 cannot be proven
+LINE 5:      JOIN t2 FOR KEY (c3) -> q1 (c1);
+                     ^
+DETAIL:  There is no matching foreign key constraint for t2 (c3) referencing q1 (c1).
+--
+-- Test CTEs
+--
+WITH
+q1 (q1_c1, q1_c2) AS
+(
+    SELECT c1, c2 FROM t1
+),
+q2 (q2_c1, q2_c2) AS
+(
+    SELECT q1_c1, q1_c2 FROM q1
+),
+q3 (q3_c3, q3_c4) AS
+(
+    SELECT c3, c4 FROM t2
+),
+q4 (q4_c3, q4_c4) AS
+(
+    SELECT q3_c3, q3_c4 FROM q3
+)
+SELECT
+    q2_c1,
+    q2_c2,
+    q4_c3,
+    q4_c4
+-- accepted
+FROM q2 JOIN q4 FOR KEY (q4_c3, q4_c4) -> q2 (q2_c1, q2_c2);
+ q2_c1 | q2_c2 | q4_c3 | q4_c4 
+-------+-------+-------+-------
+     1 |    10 |     1 |    10
+     3 |    30 |     3 |    30
+(2 rows)
+
+WITH RECURSIVE q1 AS (SELECT c1 FROM t1 UNION SELECT c1 FROM q1)
+-- rejected, reason: recursive CTEs do not expose surface FK facts
+SELECT * FROM q1 JOIN t2 FOR KEY (c3) -> q1 (c1);
+ERROR:  key join from referencing relation t2 to referenced relation q1 cannot be proven
+LINE 3: SELECT * FROM q1 JOIN t2 FOR KEY (c3) -> q1 (c1);
+                                 ^
+DETAIL:  There is no matching foreign key constraint for t2 (c3) referencing q1 (c1).
+WITH RECURSIVE rec_self(c1) AS (
+    SELECT c1 FROM t1
+    UNION ALL
+    SELECT t2.c3
+    FROM rec_self
+    -- rejected, reason: recursive self-references do not expose surface FK facts
+    JOIN t2 FOR KEY (c3) -> rec_self (c1)
+    WHERE false
+)
+SELECT * FROM rec_self;
+ERROR:  key join from referencing relation t2 to referenced relation rec_self cannot be proven
+LINE 7:     JOIN t2 FOR KEY (c3) -> rec_self (c1)
+                    ^
+DETAIL:  There is no matching foreign key constraint for t2 (c3) referencing rec_self (c1).
+-- Regression: a FOR KEY join inside a CTE body of a WITH RECURSIVE
+-- block must not raise "unrecognized node type" from the raw
+-- parse-tree walker that builds the CTE dependency graph.
+WITH RECURSIVE rec_kj AS (
+    SELECT t2.c3 FROM t1 JOIN t2 FOR KEY (c3) -> t1 (c1)
+    UNION ALL
+    SELECT c3 FROM rec_kj WHERE false
+)
+-- rejected, reason: recursive CTEs do not expose surface FK facts
+SELECT * FROM rec_kj JOIN t2 t2b FOR KEY (c3) -> rec_kj (c3);
+ERROR:  key join from referencing relation t2b to referenced relation rec_kj cannot be proven
+LINE 7: SELECT * FROM rec_kj JOIN t2 t2b FOR KEY (c3) -> rec_kj (c3)...
+                                         ^
+DETAIL:  There is no matching foreign key constraint for t2b (c3) referencing rec_kj (c3).
+-- Regression: same as above, but the FOR KEY join is in a sibling CTE
+-- of the recursive one.  makeDependencyGraph walks every CTE body in
+-- the WITH block, so the raw walker must handle KeyJoinClause here too.
+WITH RECURSIVE rec_only AS (
+    SELECT 1 AS x UNION ALL SELECT x FROM rec_only WHERE false
+), kj_sibling AS (
+    SELECT t1.c1 FROM t1 JOIN t2 FOR KEY (c3) -> t1 (c1)
+)
+SELECT * FROM rec_only, kj_sibling;
+ x | c1 
+---+----
+ 1 |  1
+ 1 |  3
+(2 rows)
+
+-- Regression: recursive CTE validation must inspect a FOR KEY FILTER
+-- before parse analysis merges it into the transformed join quals.
+WITH RECURSIVE rec_filter(n) AS (
+    SELECT 1
+    UNION ALL
+    SELECT rec_filter.n + 1
+    FROM rec_filter
+    CROSS JOIN (
+        t1 JOIN t2 FOR KEY (c3) -> t1 (c1)
+            FILTER (WHERE EXISTS (SELECT 1 FROM rec_filter r2))
+    ) kj
+    WHERE rec_filter.n < 3
+)
+SELECT * FROM rec_filter;
+ERROR:  recursive reference to query "rec_filter" must not appear within a subquery
+LINE 8:             FILTER (WHERE EXISTS (SELECT 1 FROM rec_filter r...
+                                                        ^
+--
+-- Test VIEWs
+--
+DROP VIEW v1, v2, v3;
+CREATE VIEW v1 AS
+SELECT c1 AS v1_c1, c2 AS v1_c2 FROM t1;
+CREATE VIEW v2 AS
+SELECT v1_c1 AS v2_c1, v1_c2 AS v2_c2 FROM v1;
+CREATE VIEW v3 AS
+SELECT c3 AS v3_c3, c4 AS v3_c4 FROM t2;
+CREATE VIEW v4 AS
+SELECT v3_c3 AS v4_c3, v3_c4 AS v4_c4 FROM v3;
+CREATE VIEW v5 AS
+SELECT
+    v2_c1,
+    v2_c2,
+    v4_c3,
+    v4_c4
+-- accepted
+FROM v2 JOIN v4 FOR KEY (v4_c3, v4_c4) -> v2 (v2_c1, v2_c2);
+SELECT * FROM v5;
+ v2_c1 | v2_c2 | v4_c3 | v4_c4 
+-------+-------+-------+-------
+     1 |    10 |     1 |    10
+     3 |    30 |     3 |    30
+(2 rows)
+
+--
+-- Test subqueries, CTEs, and views
+--
+WITH
+q2 (q2_c1, q2_c2) AS
+(
+    SELECT
+        q1_c1,
+        q1_c2
+    FROM
+    (
+        SELECT c1 AS q1_c1, c2 AS q1_c2 FROM t1
+    ) AS q1
+)
+SELECT
+    q2_c1,
+    q2_c2,
+    v4_c3,
+    v4_c4
+-- accepted
+FROM q2 JOIN v4 FOR KEY (v4_c3, v4_c4) -> q2 (q2_c1, q2_c2);
+ q2_c1 | q2_c2 | v4_c3 | v4_c4 
+-------+-------+-------+-------
+     1 |    10 |     1 |    10
+     3 |    30 |     3 |    30
+(2 rows)
+
+DROP VIEW v1, v2, v3, v4, v5;
+--
+-- Test subqueries, CTEs and VIEWs containing joins
+--
+SELECT
+    q1.c11,
+    q1.c12,
+    t6.c13,
+    t6.c14
+FROM
+(
+    SELECT
+        t5.c9,
+        t5.c10,
+        t5.c11,
+        t5.c12
+    FROM t5
+-- accepted
+    JOIN t1 FOR KEY (c1, c2) <- t5 (c11, c12)
+    JOIN t1 AS t1_2 FOR KEY (c1, c2) <- t5 (c11, c12)
+    JOIN t1 AS t1_3 FOR KEY (c1, c2) <- t5 (c11, c12)
+) AS q1
+JOIN t6 FOR KEY (c13, c14) -> q1 (c9, c10);
+ c11 | c12 | c13 | c14 
+-----+-----+-----+-----
+   1 |  10 |   1 |   2
+   3 |  30 |   3 |   4
+   3 |  30 |   3 |   4
+(3 rows)
+
+WITH
+q1 AS
+(
+    SELECT
+        t5.c9,
+        t5.c10,
+        t5.c11,
+        t5.c12
+    FROM t5
+-- accepted
+    JOIN t1 FOR KEY (c1, c2) <- t5 (c11, c12)
+    JOIN t1 AS t1_2 FOR KEY (c1, c2) <- t5 (c11, c12)
+    JOIN t1 AS t1_3 FOR KEY (c1, c2) <- t5 (c11, c12)
+)
+SELECT
+    q1.c11,
+    q1.c12,
+    t6.c13,
+    t6.c14
+FROM q1
+JOIN t6 FOR KEY (c13, c14) -> q1 (c9, c10);
+ c11 | c12 | c13 | c14 
+-----+-----+-----+-----
+   1 |  10 |   1 |   2
+   3 |  30 |   3 |   4
+   3 |  30 |   3 |   4
+(3 rows)
+
+CREATE VIEW v1 AS
+SELECT
+    t5.c9,
+    t5.c10,
+    t5.c11,
+    t5.c12
+FROM t5
+-- accepted
+JOIN t1 FOR KEY (c1, c2) <- t5 (c11, c12)
+JOIN t1 AS t1_2 FOR KEY (c1, c2) <- t5 (c11, c12)
+JOIN t1 AS t1_3 FOR KEY (c1, c2) <- t5 (c11, c12);
+SELECT
+    v1.c11,
+    v1.c12,
+    t6.c13,
+    t6.c14
+FROM v1
+-- accepted
+JOIN t6 FOR KEY (c13, c14) -> v1 (c9, c10);
+ c11 | c12 | c13 | c14 
+-----+-----+-----+-----
+   1 |  10 |   1 |   2
+   3 |  30 |   3 |   4
+   3 |  30 |   3 |   4
+(3 rows)
+
+DROP VIEW v1;
+--
+-- Test disallowed filtering of referenced table
+--
+CREATE VIEW v1 AS
+SELECT * FROM t1 WHERE c1 > 0;
+CREATE VIEW v2 AS
+SELECT * FROM t2 WHERE c3 > 0;
+-- rejected, reason: v1 is filtered (referenced) but t2 (referencing) applies no
+-- equivalent filter — the matched-filter rule cannot rescue this
+SELECT * FROM v1 JOIN t2 FOR KEY (c3) -> v1 (c1);
+ERROR:  key join from referencing relation t2 to referenced relation v1 cannot be proven
+LINE 1: SELECT * FROM v1 JOIN t2 FOR KEY (c3) -> v1 (c1);
+                                 ^
+DETAIL:  Not every t2 (c3) value can be proven to have a matching v1 row. Referenced relation v1 is filtered before this key join. The relevant operation occurs inside view public.v1.
+-- accepted, filtering allowed since v2 is the referencing table
+SELECT * FROM t1 JOIN v2 FOR KEY (c3) -> t1 (c1);
+ c1 | c2 | c3 | c4 
+----+----+----+----
+  1 | 10 |  1 | 10
+  3 | 30 |  3 | 30
+(2 rows)
+
+-- rejected, reason: matching inequalities are not accepted by the conservative
+-- matched-filter rule, even when the expression trees line up under the FK
+-- column mapping.
+SELECT * FROM v1 JOIN v2 FOR KEY (c3) -> v1 (c1);
+ERROR:  key join from referencing relation v2 to referenced relation v1 cannot be proven
+LINE 1: SELECT * FROM v1 JOIN v2 FOR KEY (c3) -> v1 (c1);
+                                 ^
+DETAIL:  Not every v2 (c3) value can be proven to have a matching v1 row. Referenced relation v1 is filtered before this key join. The relevant operation occurs inside view public.v1.
+-- rejected, reason: referenced-side HAVING filter is not a matched key filter
+SELECT * FROM
+(
+    SELECT c1, count(*) FROM t1 GROUP BY c1 HAVING c2 > 100
+) AS u
+JOIN t2 FOR KEY (c3) -> u (c1);
+ERROR:  key join from referencing relation t2 to referenced relation u cannot be proven
+LINE 5: JOIN t2 FOR KEY (c3) -> u (c1);
+                ^
+DETAIL:  Not every t2 (c3) value can be proven to have a matching u row. Referenced relation u may lose rows before this key join because of HAVING, LIMIT, OFFSET, FOR UPDATE or TABLESAMPLE.
+-- rejected, reason: u is filtered and is the referenced table
+SELECT * FROM (SELECT c1 FROM t1 LIMIT 1) AS u
+JOIN t2 FOR KEY (c3) -> u (c1);
+ERROR:  key join from referencing relation t2 to referenced relation u cannot be proven
+LINE 2: JOIN t2 FOR KEY (c3) -> u (c1);
+                ^
+DETAIL:  Not every t2 (c3) value can be proven to have a matching u row. Referenced relation u may lose rows before this key join because of HAVING, LIMIT, OFFSET, FOR UPDATE or TABLESAMPLE.
+-- rejected, reason: u is filtered and is the referenced table
+SELECT * FROM (SELECT c1 FROM t1 OFFSET 1) AS u
+JOIN t2 FOR KEY (c3) -> u (c1);
+ERROR:  key join from referencing relation t2 to referenced relation u cannot be proven
+LINE 2: JOIN t2 FOR KEY (c3) -> u (c1);
+                ^
+DETAIL:  Not every t2 (c3) value can be proven to have a matching u row. Referenced relation u may lose rows before this key join because of HAVING, LIMIT, OFFSET, FOR UPDATE or TABLESAMPLE.
+WITH q2 AS
+(
+    SELECT * FROM t5 WHERE t5.c11 > 0
+)
+SELECT
+    q1.c11,
+    q1.c12,
+    t7.c15,
+    t7.c16
+FROM
+(
+    SELECT
+        q2.c9,
+        q2.c10,
+        q2.c11,
+        q2.c12
+    FROM q2
+-- rejected, reason: referenced-side q1 is filtered on a non-key column for c9,c10 coverage
+    JOIN t1 FOR KEY (c1, c2) <- q2 (c11, c12)
+) AS q1
+JOIN t7 FOR KEY (c15, c16) -> q1 (c9, c10);
+ERROR:  key join from referencing relation t7 to referenced relation q1 cannot be proven
+LINE 21: JOIN t7 FOR KEY (c15, c16) -> q1 (c9, c10);
+                 ^
+DETAIL:  Not every t7 (c15, c16) value can be proven to have a matching q1 row. Referenced relation q1 is filtered before this key join.
+--
+-- Test allowed joins not affecting uniqueness
+--
+SELECT
+    q1.c11,
+    q1.c12,
+    t6.c13,
+    t6.c14
+FROM
+(
+    SELECT
+        t5.c9,
+        t5.c10,
+        t5.c11,
+        t5.c12
+    FROM t5
+-- accepted
+    JOIN t1 FOR KEY (c1, c2) <- t5 (c11, c12)
+) AS q1
+JOIN t6 FOR KEY (c13, c14) -> q1 (c9, c10);
+ c11 | c12 | c13 | c14 
+-----+-----+-----+-----
+   1 |  10 |   1 |   2
+   3 |  30 |   3 |   4
+   3 |  30 |   3 |   4
+(3 rows)
+
+--
+-- Test disallowed non-unique referenced table
+--
+SELECT
+    q1.c11,
+    q1.c12,
+    t7.c15,
+    t7.c16
+FROM
+(
+    SELECT
+        t5.c9,
+        t5.c10,
+        t5.c11,
+        t5.c12
+    FROM t5
+-- accepted
+    JOIN t1 FOR KEY (c1, c2) <- t5 (c11, c12)
+    JOIN t6 FOR KEY (c13, c14) -> t5 (c9, c10)
+) AS q1
+-- rejected, reason: inner key join duplicates the referenced key
+JOIN t7 FOR KEY (c15, c16) -> q1 (c9, c10);
+ERROR:  key join from referencing relation t7 to referenced relation q1 cannot be proven
+LINE 19: JOIN t7 FOR KEY (c15, c16) -> q1 (c9, c10);
+                 ^
+DETAIL:  Referenced columns q1 (c9, c10) are not proven unique. A preceding join may duplicate rows from referenced relation q1.
+--
+-- Test revalidation of views
+--
+CREATE TABLE addresses
+(
+    id           INTEGER      NOT NULL,
+    street       VARCHAR(255) NOT NULL,
+    city         VARCHAR(100) NOT NULL,
+    state        VARCHAR(100) NOT NULL,
+    country_code CHAR(2)      NOT NULL,
+    zip_code     VARCHAR(20)  NOT NULL,
+    CONSTRAINT addresses_pkey PRIMARY KEY (id)
+);
+CREATE TABLE customers
+(
+    id         INTEGER      NOT NULL,
+    name       VARCHAR(255) NOT NULL,
+    address_id INTEGER      NOT NULL,
+    CONSTRAINT customers_pkey            PRIMARY KEY (id),
+    CONSTRAINT customers_address_id_fkey FOREIGN KEY (address_id) REFERENCES addresses (id)
+);
+CREATE TABLE orders
+(
+    id           BIGINT         NOT NULL,
+    order_date   DATE           NOT NULL,
+    amount       DECIMAL(10, 2) NOT NULL,
+    customer_id  INTEGER        NOT NULL,
+    CONSTRAINT orders_pkey             PRIMARY KEY (id),
+    CONSTRAINT orders_customer_id_fkey FOREIGN KEY (customer_id) REFERENCES customers (id)
+);
+CREATE VIEW customer_details AS
+SELECT
+    c.id AS customer_id,
+    c.name AS customer_name,
+    a.street,
+    a.city,
+    a.state,
+    a.country_code,
+    a.zip_code
+FROM customers AS c
+-- accepted
+JOIN addresses AS a FOR KEY (id) <- c (address_id);
+CREATE VIEW orders_by_country AS
+SELECT
+    cd.country_code,
+    COUNT(*) AS order_count,
+    SUM(o.amount) AS total_amount
+FROM orders AS o
+-- accepted
+JOIN customer_details AS cd FOR KEY (customer_id) <- o (customer_id)
+GROUP BY ROLLUP (cd.country_code);
+-- Test NOT NULL constraint dependency: should error because customer_details
+-- (and transitively orders_by_country) depends on address_id being NOT NULL
+ALTER TABLE customers ALTER COLUMN address_id DROP NOT NULL;
+ERROR:  cannot drop constraint customers_address_id_not_null on table customers because other objects depend on it
+DETAIL:  view customer_details depends on constraint customers_address_id_not_null on table customers
+view orders_by_country depends on view customer_details
+HINT:  Use DROP ... CASCADE to drop the dependent objects too.
+-- Replace customer_details with LEFT JOIN version.  Revalidating
+-- orders_by_country only shrinks its proof dependencies, so the replacement
+-- can proceed without rewriting orders_by_country.
+CREATE OR REPLACE VIEW customer_details AS
+SELECT
+    c.id AS customer_id,
+    c.name AS customer_name,
+    a.street,
+    a.city,
+    a.state,
+    a.country_code,
+    a.zip_code
+FROM customers AS c
+-- accepted
+LEFT JOIN addresses AS a FOR KEY (id) <- c (address_id);
+-- DROP NOT NULL succeeds because recreating customer_details as a LEFT JOIN
+-- removed its stored proof dependency on customers.address_id NOT NULL.
+-- orders_by_country was revalidated against the new producer surface; its
+-- key join needs customer_id uniqueness and row coverage, not that internal
+-- NOT NULL proof.
+ALTER TABLE customers ALTER COLUMN address_id DROP NOT NULL;
+-- Restore NOT NULL and the inner-join producer for subsequent tests.
+DROP VIEW orders_by_country;
+ALTER TABLE customers ALTER COLUMN address_id SET NOT NULL;
+CREATE OR REPLACE VIEW customer_details AS
+SELECT
+    c.id AS customer_id,
+    c.name AS customer_name,
+    a.street,
+    a.city,
+    a.state,
+    a.country_code,
+    a.zip_code
+FROM customers AS c
+-- accepted
+JOIN addresses AS a FOR KEY (id) <- c (address_id);
+CREATE VIEW orders_by_country AS
+SELECT
+    cd.country_code,
+    COUNT(*) AS order_count,
+    SUM(o.amount) AS total_amount
+FROM orders AS o
+-- accepted
+JOIN customer_details AS cd FOR KEY (customer_id) <- o (customer_id)
+GROUP BY ROLLUP (cd.country_code);
+--
+-- Test NOT NULL constraint tracking for various join types
+-- Create simple test tables for these tests
+--
+CREATE TABLE t1_nn (t1_id INTEGER PRIMARY KEY);
+CREATE TABLE t2_nn (
+    t2_id INTEGER PRIMARY KEY,
+    t2_t1_id_nn INTEGER NOT NULL REFERENCES t1_nn(t1_id),
+    t2_t1_id_nullable INTEGER REFERENCES t1_nn(t1_id)
+);
+INSERT INTO t1_nn VALUES (1), (2), (3);
+INSERT INTO t2_nn VALUES (10, 1, 1), (20, 2, NULL);
+-- Test 1: LEFT JOIN where referencing is on inner side (right)
+-- accepted
+CREATE VIEW v_left_ref_inner AS
+SELECT * FROM t1_nn LEFT JOIN t2_nn FOR KEY (t2_t1_id_nn) -> t1_nn (t1_id);
+ALTER TABLE t2_nn ALTER COLUMN t2_t1_id_nn DROP NOT NULL; -- rejected, reason: view depends on the NOT NULL proof
+ERROR:  cannot drop constraint t2_nn_t2_t1_id_nn_not_null on table t2_nn because other objects depend on it
+DETAIL:  view v_left_ref_inner depends on constraint t2_nn_t2_t1_id_nn_not_null on table t2_nn
+HINT:  Use DROP ... CASCADE to drop the dependent objects too.
+DROP VIEW v_left_ref_inner;
+-- Test 2: LEFT JOIN where referencing is on outer side (left)
+-- Producer facts alone do not create stored dependencies.
+CREATE VIEW v_left_ref_outer AS
+-- accepted
+SELECT * FROM t2_nn LEFT JOIN t1_nn FOR KEY (t1_id) <- t2_nn (t2_t1_id_nn);
+ALTER TABLE t2_nn ALTER COLUMN t2_t1_id_nn DROP NOT NULL; -- accepted, reason: no stored proof consumes the NOT NULL fact
+ALTER TABLE t2_nn ALTER COLUMN t2_t1_id_nn SET NOT NULL;
+DROP VIEW v_left_ref_outer;
+-- Test 3: RIGHT JOIN where referencing is on inner side (left)
+-- accepted
+CREATE VIEW v_right_ref_inner AS
+SELECT * FROM t2_nn RIGHT JOIN t1_nn FOR KEY (t1_id) <- t2_nn (t2_t1_id_nn);
+ALTER TABLE t2_nn ALTER COLUMN t2_t1_id_nn DROP NOT NULL; -- rejected, reason: view depends on the NOT NULL proof
+ERROR:  cannot drop constraint t2_nn_t2_t1_id_nn_not_null on table t2_nn because other objects depend on it
+DETAIL:  view v_right_ref_inner depends on constraint t2_nn_t2_t1_id_nn_not_null on table t2_nn
+HINT:  Use DROP ... CASCADE to drop the dependent objects too.
+DROP VIEW v_right_ref_inner;
+-- Test 4: RIGHT JOIN where referencing is on outer side (right)
+-- Producer facts alone do not create stored dependencies.
+CREATE VIEW v_right_ref_outer AS
+-- accepted
+SELECT * FROM t1_nn RIGHT JOIN t2_nn FOR KEY (t2_t1_id_nn) -> t1_nn (t1_id);
+ALTER TABLE t2_nn ALTER COLUMN t2_t1_id_nn DROP NOT NULL; -- accepted, reason: no stored proof consumes the NOT NULL fact
+ALTER TABLE t2_nn ALTER COLUMN t2_t1_id_nn SET NOT NULL;
+DROP VIEW v_right_ref_outer;
+-- Test 5: FULL JOIN preserves referencing rows, so the key-join proof does
+-- not consume the NOT NULL fact.
+CREATE VIEW v_full_1 AS
+-- accepted
+SELECT * FROM t1_nn FULL JOIN t2_nn FOR KEY (t2_t1_id_nn) -> t1_nn (t1_id);
+ALTER TABLE t2_nn ALTER COLUMN t2_t1_id_nn DROP NOT NULL; -- accepted, reason: no stored proof consumes the NOT NULL fact
+ALTER TABLE t2_nn ALTER COLUMN t2_t1_id_nn SET NOT NULL;
+DROP VIEW v_full_1;
+CREATE VIEW v_full_2 AS
+-- accepted
+SELECT * FROM t2_nn FULL JOIN t1_nn FOR KEY (t1_id) <- t2_nn (t2_t1_id_nn);
+ALTER TABLE t2_nn ALTER COLUMN t2_t1_id_nn DROP NOT NULL; -- accepted, reason: no stored proof consumes the NOT NULL fact
+ALTER TABLE t2_nn ALTER COLUMN t2_t1_id_nn SET NOT NULL;
+DROP VIEW v_full_2;
+-- A second-layer stored key join that consumes a producer view's not-null
+-- fact activates the underlying NOT NULL dependency.
+CREATE VIEW v_notnull_producer AS
+SELECT t2_id, t2_t1_id_nn FROM t2_nn;
+CREATE VIEW v_notnull_consumer AS
+-- accepted
+SELECT *
+FROM t1_nn JOIN v_notnull_producer p
+    FOR KEY (t2_t1_id_nn) -> t1_nn (t1_id);
+ALTER TABLE t2_nn ALTER COLUMN t2_t1_id_nn DROP NOT NULL; -- rejected, reason: consumer view depends on the NOT NULL proof
+ERROR:  cannot drop constraint t2_nn_t2_t1_id_nn_not_null on table t2_nn because other objects depend on it
+DETAIL:  view v_notnull_consumer depends on constraint t2_nn_t2_t1_id_nn_not_null on table t2_nn
+HINT:  Use DROP ... CASCADE to drop the dependent objects too.
+DROP VIEW v_notnull_consumer;
+ALTER TABLE t2_nn ALTER COLUMN t2_t1_id_nn DROP NOT NULL; -- accepted, reason: producer view alone does not store the fact
+SELECT *
+FROM t1_nn JOIN v_notnull_producer p
+-- rejected, reason: view facts are recomputed after the NOT NULL constraint was dropped
+    FOR KEY (t2_t1_id_nn) -> t1_nn (t1_id);
+ERROR:  key join from referencing relation p to referenced relation t1_nn cannot be proven
+LINE 4:     FOR KEY (t2_t1_id_nn) -> t1_nn (t1_id);
+            ^
+DETAIL:  This inner join could filter rows from p. Referencing columns p (t2_t1_id_nn) can be null.
+ALTER TABLE t2_nn ALTER COLUMN t2_t1_id_nn SET NOT NULL;
+DROP VIEW v_notnull_producer;
+--
+-- Test nullable referencing columns
+--
+-- N1: Nullable FK, INNER JOIN → error
+SELECT * FROM t2_nn
+-- rejected, reason: nullable referencing column is not preserved by an inner key join
+JOIN t1_nn FOR KEY (t1_id) <- t2_nn (t2_t1_id_nullable);
+ERROR:  key join from referencing relation t2_nn to referenced relation t1_nn cannot be proven
+LINE 3: JOIN t1_nn FOR KEY (t1_id) <- t2_nn (t2_t1_id_nullable);
+                   ^
+DETAIL:  This inner join could filter rows from t2_nn. Referencing columns t2_nn (t2_t1_id_nullable) can be null.
+-- N3: Nullable FK, LEFT JOIN preserving referencing side → accepted
+SELECT * FROM t2_nn
+-- accepted
+LEFT JOIN t1_nn FOR KEY (t1_id) <- t2_nn (t2_t1_id_nullable);
+ t2_id | t2_t1_id_nn | t2_t1_id_nullable | t1_id 
+-------+-------------+-------------------+-------
+    10 |           1 |                 1 |     1
+    20 |           2 |                   |      
+(2 rows)
+
+-- N4: Nullable FK, RIGHT JOIN preserving referencing side → accepted
+SELECT * FROM t1_nn
+-- accepted
+RIGHT JOIN t2_nn FOR KEY (t2_t1_id_nullable) -> t1_nn (t1_id);
+ t1_id | t2_id | t2_t1_id_nn | t2_t1_id_nullable 
+-------+-------+-------------+-------------------
+     1 |    10 |           1 |                 1
+       |    20 |           2 |                  
+(2 rows)
+
+-- N5: Nullable FK, FULL JOIN → accepted
+SELECT * FROM t2_nn
+-- accepted
+FULL JOIN t1_nn FOR KEY (t1_id) <- t2_nn (t2_t1_id_nullable);
+ t2_id | t2_t1_id_nn | t2_t1_id_nullable | t1_id 
+-------+-------------+-------------------+-------
+    10 |           1 |                 1 |     1
+       |             |                   |     2
+       |             |                   |     3
+    20 |           2 |                   |      
+(4 rows)
+
+-- N7: Nullable FK, LEFT JOIN but referencing on inner side → error
+SELECT * FROM t1_nn
+-- rejected, reason: nullable referencing column is on the non-preserved side
+LEFT JOIN t2_nn FOR KEY (t2_t1_id_nullable) -> t1_nn (t1_id);
+ERROR:  key join from referencing relation t2_nn to referenced relation t1_nn cannot be proven
+LINE 3: LEFT JOIN t2_nn FOR KEY (t2_t1_id_nullable) -> t1_nn (t1_id)...
+                        ^
+DETAIL:  This join could filter rows from t2_nn. Referencing columns t2_nn (t2_t1_id_nullable) can be null.
+-- NE1: NOT NULL FK col null-extended by prior FK LEFT JOIN → null-extended error
+-- t1_nn a LEFT JOIN t2_nn makes t2_t1_id_nn null-extended; the subsequent INNER key join
+-- on that column should fire the null-extended branch of the error.
+SELECT * FROM
+    t1_nn a
+    LEFT JOIN t2_nn FOR KEY (t2_t1_id_nn) -> a (t1_id)
+-- rejected, reason: FK column was null-extended by the prior outer join
+    JOIN t1_nn b FOR KEY (t1_id) <- t2_nn (t2_t1_id_nn);
+ERROR:  key join from referencing relation t2_nn to referenced relation b cannot be proven
+LINE 5:     JOIN t1_nn b FOR KEY (t1_id) <- t2_nn (t2_t1_id_nn);
+                         ^
+DETAIL:  This inner join could filter rows from t2_nn. Referencing columns t2_nn (t2_t1_id_nn) can be null because a preceding outer join can null-extend the referencing side.
+-- NE2: NOT NULL FK col null-extended through prior outer key joins → error
+CREATE TABLE t1_nn_chain (t1_id INTEGER PRIMARY KEY);
+CREATE TABLE t2_nn_chain (
+    t2_id INTEGER PRIMARY KEY,
+    t2_t1_id INTEGER NOT NULL REFERENCES t1_nn_chain(t1_id)
+);
+CREATE TABLE t3_nn_chain (
+    t3_id INTEGER PRIMARY KEY,
+    t3_t2_id INTEGER NOT NULL UNIQUE REFERENCES t2_nn_chain(t2_id)
+);
+INSERT INTO t1_nn_chain VALUES (1), (2);
+INSERT INTO t2_nn_chain VALUES (10, 1), (20, 2);
+INSERT INTO t3_nn_chain VALUES (100, 10);
+SELECT * FROM
+    t2_nn_chain m
+-- accepted
+    FULL JOIN t3_nn_chain l FOR KEY (t3_t2_id) -> m (t2_id)
+    LEFT JOIN t2_nn_chain m2 FOR KEY (t2_id) <- l (t3_t2_id)
+-- rejected, reason: FK column was null-extended through prior outer key joins
+    JOIN t1_nn_chain r FOR KEY (t1_id) <- m2 (t2_t1_id);
+ERROR:  key join from referencing relation m2 to referenced relation r cannot be proven
+LINE 7:     JOIN t1_nn_chain r FOR KEY (t1_id) <- m2 (t2_t1_id);
+                               ^
+DETAIL:  This inner join could filter rows from m2. Referencing columns m2 (t2_t1_id) can be null because a preceding outer join can null-extend the referencing side.
+DROP TABLE t3_nn_chain, t2_nn_chain, t1_nn_chain;
+-- NE3: outer padding null-extends every base table in a nested subtree
+CREATE TABLE ne3_a (id INTEGER PRIMARY KEY);
+CREATE TABLE ne3_e (id INTEGER PRIMARY KEY);
+CREATE TABLE ne3_c (
+    id   INTEGER PRIMARY KEY,
+    e_id INTEGER NOT NULL REFERENCES ne3_e(id)
+);
+CREATE TABLE ne3_b (
+    id   INTEGER PRIMARY KEY,
+    a_id INTEGER NOT NULL REFERENCES ne3_a(id),
+    c_id INTEGER NOT NULL REFERENCES ne3_c(id)
+);
+INSERT INTO ne3_a VALUES (1), (2);
+INSERT INTO ne3_e VALUES (10);
+INSERT INTO ne3_c VALUES (100, 10);
+INSERT INTO ne3_b VALUES (1000, 1, 100);
+SELECT * FROM
+(
+    SELECT ne3_a.id AS a_id, ne3_c.e_id AS c_e_id
+    FROM ne3_a
+-- accepted
+    LEFT JOIN (ne3_c JOIN ne3_b FOR KEY (c_id) -> ne3_c (id))
+        FOR KEY (a_id) -> ne3_a (id)
+) q
+-- rejected, reason: c_e_id is null-extended by the preceding LEFT key join
+JOIN ne3_e FOR KEY (id) <- q (c_e_id);
+ERROR:  key join from referencing relation q to referenced relation ne3_e cannot be proven
+LINE 10: JOIN ne3_e FOR KEY (id) <- q (c_e_id);
+                    ^
+DETAIL:  This inner join could filter rows from q. Referencing columns q (c_e_id) can be null because a preceding outer join can null-extend the referencing side.
+CREATE VIEW ne3_right_nested_v AS
+SELECT ne3_a.id AS a_id, ne3_c.e_id AS c_e_id
+FROM ne3_a
+-- accepted
+LEFT JOIN (ne3_c JOIN ne3_b FOR KEY (c_id) -> ne3_c (id))
+    FOR KEY (a_id) -> ne3_a (id);
+SELECT *
+FROM ne3_right_nested_v q
+-- rejected, reason: c_e_id is null-extended by the stored right-nested key join
+JOIN ne3_e FOR KEY (id) <- q (c_e_id);
+ERROR:  key join from referencing relation q to referenced relation ne3_e cannot be proven
+LINE 4: JOIN ne3_e FOR KEY (id) <- q (c_e_id);
+                   ^
+DETAIL:  This inner join could filter rows from q. Referencing columns q (c_e_id) can be null because a preceding outer join can null-extend the referencing side. The relevant operation occurs inside view public.ne3_right_nested_v.
+DROP VIEW ne3_right_nested_v;
+DROP TABLE ne3_b, ne3_c, ne3_e, ne3_a;
+DROP TABLE t2_nn, t1_nn;
+CREATE TABLE customer_addresses
+(
+    customer_id INTEGER NOT NULL,
+    address_id INTEGER NOT NULL,
+    CONSTRAINT customer_addresses_pkey             PRIMARY KEY (customer_id, address_id),
+    CONSTRAINT customer_addresses_customer_id_fkey FOREIGN KEY (customer_id) REFERENCES customers (id),
+    CONSTRAINT customer_addresses_address_id_fkey  FOREIGN KEY (address_id) REFERENCES addresses (id)
+);
+-- Replacing customer_details to route through customer_addresses would make
+-- customer_details.customer_id appear once per customer address.  The
+-- dependent orders_by_country view needs customer_details.customer_id to
+-- remain unique, so revalidation must reject the producer replacement.
+CREATE OR REPLACE VIEW customer_details AS
+SELECT
+    c.id AS customer_id,
+    c.name AS customer_name,
+    a.street,
+    a.city,
+    a.state,
+    a.country_code,
+    a.zip_code
+FROM customers AS c
+JOIN customer_addresses AS ca FOR KEY (customer_id) -> c (id)
+JOIN addresses AS a FOR KEY (id) <- ca (address_id);
+ERROR:  key join from referencing relation o to referenced relation cd cannot be proven
+DETAIL:  Referenced columns cd (customer_id) are not proven unique. A preceding join may duplicate rows from referenced relation cd.
+--
+-- Test various error conditions
+--
+-- rejected, reason: arrow alias is not present in the left join operand
+SELECT * FROM t1 JOIN t2 FOR KEY (c3, c4) -> t3 (c1, c2);
+ERROR:  key join alias "t3" is not present in the left join operand
+LINE 1: SELECT * FROM t1 JOIN t2 FOR KEY (c3, c4) -> t3 (c1, c2);
+                                 ^
+CREATE SCHEMA key_join_alias_ambiguous;
+CREATE TABLE key_join_alias_ambiguous.t1 (c1 int PRIMARY KEY);
+-- rejected, reason: arrow alias matches two visible left-operand relations
+SELECT *
+FROM public.t1 CROSS JOIN key_join_alias_ambiguous.t1
+JOIN t2 FOR KEY (c3) -> t1 (c1);
+ERROR:  table reference "t1" is ambiguous
+LINE 3: JOIN t2 FOR KEY (c3) -> t1 (c1);
+                ^
+DROP SCHEMA key_join_alias_ambiguous CASCADE;
+NOTICE:  drop cascades to table key_join_alias_ambiguous.t1
+-- rejected, reason: referencing and referenced column counts differ
+SELECT * FROM t1 JOIN t2 FOR KEY (c3, c4) -> t1 (c1);
+ERROR:  key join column lists must have the same length
+LINE 1: SELECT * FROM t1 JOIN t2 FOR KEY (c3, c4) -> t1 (c1);
+                                 ^
+-- rejected, reason: referencing and referenced column counts differ
+SELECT * FROM t1 JOIN t2 FOR KEY (c3) -> t1 (c1, c2);
+ERROR:  key join column lists must have the same length
+LINE 1: SELECT * FROM t1 JOIN t2 FOR KEY (c3) -> t1 (c1, c2);
+                                 ^
+-- rejected, reason: referencing and referenced column counts differ
+SELECT * FROM t1 JOIN t2 FOR KEY (c3, c4) -> t1 (c1, c2, c3);
+ERROR:  key join column lists must have the same length
+LINE 1: SELECT * FROM t1 JOIN t2 FOR KEY (c3, c4) -> t1 (c1, c2, c3)...
+                                 ^
+-- rejected, reason: referencing column c5 does not exist
+SELECT * FROM t1 JOIN t2 FOR KEY (c3, c4, c5) -> t1 (c1, c2);
+ERROR:  key join column lists must have the same length
+LINE 1: SELECT * FROM t1 JOIN t2 FOR KEY (c3, c4, c5) -> t1 (c1, c2)...
+                                 ^
+CREATE FUNCTION t2() RETURNS TABLE (c3 INTEGER, c4 INTEGER)
+LANGUAGE sql
+RETURN (1, 2);
+-- rejected, reason: functions in FROM do not expose surface FK facts
+SELECT * FROM t1 JOIN t2() FOR KEY (c3, c4) -> t1 (c1, c2);
+ERROR:  key join from referencing relation t2 to referenced relation t1 cannot be proven
+LINE 1: SELECT * FROM t1 JOIN t2() FOR KEY (c3, c4) -> t1 (c1, c2);
+                                   ^
+DETAIL:  There is no matching foreign key constraint for t2 (c3, c4) referencing t1 (c1, c2).
+-- rejected, reason: referenced column c5 does not exist
+SELECT * FROM t1 JOIN t2 FOR KEY (c3, c4) -> t1 (c1, c5);
+ERROR:  referenced column "c5" does not exist
+LINE 1: SELECT * FROM t1 JOIN t2 FOR KEY (c3, c4) -> t1 (c1, c5);
+                                 ^
+-- rejected, reason: referenced column name is duplicated in the left operand
+SELECT *
+FROM (SELECT c1, c2 AS c1 FROM t1) AS dup_ref
+JOIN t2 FOR KEY (c3) -> dup_ref (c1);
+ERROR:  referenced column "c1" is ambiguous
+LINE 3: JOIN t2 FOR KEY (c3) -> dup_ref (c1);
+                ^
+-- rejected, reason: referencing column is not exposed by the left join operand
+SELECT *
+FROM t2 AS fk_keep JOIN t2 AS fk_hidden USING (c3)
+JOIN t1 FOR KEY (c1) <- fk_hidden (c3);
+ERROR:  referencing column "c3" is not exposed by the join operand
+LINE 3: JOIN t1 FOR KEY (c1) <- fk_hidden (c3);
+                ^
+-- rejected, reason: referenced column is not exposed by the left join operand
+SELECT *
+FROM t1 AS pk_keep JOIN t1 AS pk_hidden USING (c1)
+JOIN t2 FOR KEY (c3) -> pk_hidden (c1);
+ERROR:  referenced column "c1" is not exposed by the join operand
+LINE 3: JOIN t2 FOR KEY (c3) -> pk_hidden (c1);
+                ^
+DROP VIEW v1, v2;
+CREATE VIEW v1 AS SELECT c1 AS c1_1, c1 AS c1_2, c2 AS c2_1, c2 AS c2_2 FROM t1;
+CREATE VIEW v2 AS SELECT c3 AS c3_1, c3 AS c3_2, c4 AS c4_1, c4 AS c4_2 FROM t2;
+SELECT * FROM v1 JOIN t2 FOR KEY (c3, c4) -> v1 (c1_1, c2_1); -- accepted
+ c1_1 | c1_2 | c2_1 | c2_2 | c3 | c4 
+------+------+------+------+----+----
+    1 |    1 |   10 |   10 |  1 | 10
+    3 |    3 |   30 |   30 |  3 | 30
+(2 rows)
+
+SELECT * FROM v1 JOIN t2 FOR KEY (c3, c4) -> v1 (c1_2, c2_1); -- accepted
+ c1_1 | c1_2 | c2_1 | c2_2 | c3 | c4 
+------+------+------+------+----+----
+    1 |    1 |   10 |   10 |  1 | 10
+    3 |    3 |   30 |   30 |  3 | 30
+(2 rows)
+
+SELECT * FROM v1 JOIN t2 FOR KEY (c3, c4) -> v1 (c1_1, c2_2); -- accepted
+ c1_1 | c1_2 | c2_1 | c2_2 | c3 | c4 
+------+------+------+------+----+----
+    1 |    1 |   10 |   10 |  1 | 10
+    3 |    3 |   30 |   30 |  3 | 30
+(2 rows)
+
+SELECT * FROM v1 JOIN t2 FOR KEY (c3, c4) -> v1 (c1_2, c2_2); -- accepted
+ c1_1 | c1_2 | c2_1 | c2_2 | c3 | c4 
+------+------+------+------+----+----
+    1 |    1 |   10 |   10 |  1 | 10
+    3 |    3 |   30 |   30 |  3 | 30
+(2 rows)
+
+SELECT * FROM v1 JOIN v2 FOR KEY (c3_2, c4_1) -> v1 (c1_2, c2_1); -- accepted
+ c1_1 | c1_2 | c2_1 | c2_2 | c3_1 | c3_2 | c4_1 | c4_2 
+------+------+------+------+------+------+------+------
+    1 |    1 |   10 |   10 |    1 |    1 |   10 |   10
+    3 |    3 |   30 |   30 |    3 |    3 |   30 |   30
+(2 rows)
+
+CREATE VIEW v_dup_alias_fk AS
+SELECT * FROM v1 JOIN v2 FOR KEY (c3_2, c4_1) -> v1 (c1_2, c2_2);
+SELECT pg_get_viewdef('v_dup_alias_fk'::regclass, true);
+                    pg_get_viewdef                     
+-------------------------------------------------------
+  SELECT v1.c1_1,                                     +
+     v1.c1_2,                                         +
+     v1.c2_1,                                         +
+     v1.c2_2,                                         +
+     v2.c3_1,                                         +
+     v2.c3_2,                                         +
+     v2.c4_1,                                         +
+     v2.c4_2                                          +
+    FROM v1                                           +
+      JOIN v2 FOR KEY (c3_2, c4_1) -> v1 (c1_2, c2_2);
+(1 row)
+
+DROP VIEW v_dup_alias_fk;
+-- rejected, reason: selected referenced columns do not map to the composite key
+SELECT * FROM v1 JOIN t2 FOR KEY (c3, c4) -> v1 (c1_1, c1_2);
+ERROR:  key join from referencing relation t2 to referenced relation v1 cannot be proven
+LINE 1: SELECT * FROM v1 JOIN t2 FOR KEY (c3, c4) -> v1 (c1_1, c1_2)...
+                                 ^
+DETAIL:  Referenced columns v1 (c1_1, c1_2) are not proven unique.
+-- rejected, reason: referenced column does not exist
+SELECT * FROM v1 JOIN t2 FOR KEY (c3, c4) -> v1 (c1_1, nonexistent);
+ERROR:  referenced column "nonexistent" does not exist
+LINE 1: SELECT * FROM v1 JOIN t2 FOR KEY (c3, c4) -> v1 (c1_1, nonex...
+                                 ^
+/*
+ * We don't need to check for duplicate columns,
+ * since there is already such a check for foreign key constraints.
+ * Let's test it anyway.
+ */
+-- rejected, reason: duplicate selected columns do not match a foreign key
+SELECT * FROM v1 JOIN t2 FOR KEY (c3, c3) -> v1 (c1_1, c1_1);
+ERROR:  key join from referencing relation t2 to referenced relation v1 cannot be proven
+LINE 7: SELECT * FROM v1 JOIN t2 FOR KEY (c3, c3) -> v1 (c1_1, c1_1)...
+                                 ^
+DETAIL:  There is no matching foreign key constraint for t2 (c3, c3) referencing v1 (c1_1, c1_1).
+-- rejected, reason: duplicate referencing aliases do not map to the composite FK
+SELECT * FROM v1 JOIN v2 FOR KEY (c3_1, c3_2) -> v1 (c1_1, c2_1);
+ERROR:  key join from referencing relation v2 to referenced relation v1 cannot be proven
+LINE 1: SELECT * FROM v1 JOIN v2 FOR KEY (c3_1, c3_2) -> v1 (c1_1, c...
+                                 ^
+DETAIL:  There is no matching foreign key constraint for v2 (c3_1, c3_2) referencing v1 (c1_1, c2_1).
+DROP VIEW v1;
+CREATE VIEW v1 AS SELECT c1+0 AS c1_1, c1 AS c1_2, c2 AS c2_1, c2 AS c2_2 FROM t1;
+-- rejected, reason: referenced key column is an expression, not a direct Var
+SELECT * FROM v1 JOIN t2 FOR KEY (c3, c4) -> v1 (c1_1, c2_1);
+ERROR:  key join from referencing relation t2 to referenced relation v1 cannot be proven
+LINE 1: SELECT * FROM v1 JOIN t2 FOR KEY (c3, c4) -> v1 (c1_1, c2_1)...
+                                 ^
+DETAIL:  Referenced columns v1 (c1_1, c2_1) are not proven unique.
+SELECT * FROM v1 JOIN t2 FOR KEY (c3, c4) -> v1 (c1_2, c2_1); -- accepted
+ c1_1 | c1_2 | c2_1 | c2_2 | c3 | c4 
+------+------+------+------+----+----
+    1 |    1 |   10 |   10 |  1 | 10
+    3 |    3 |   30 |   30 |  3 | 30
+(2 rows)
+
+SELECT * FROM t1 JOIN
+(
+    SELECT c3, c4 FROM t2
+    UNION ALL
+    SELECT c3, c4 FROM t2
+-- rejected, reason: set operations do not expose surface FK facts
+) AS u FOR KEY (c3, c4) -> t1 (c1, c2);
+ERROR:  key join from referencing relation u to referenced relation t1 cannot be proven
+LINE 7: ) AS u FOR KEY (c3, c4) -> t1 (c1, c2);
+               ^
+DETAIL:  There is no matching foreign key constraint for u (c3, c4) referencing t1 (c1, c2).
+-- rejected, reason: key join on subquery with GROUPING SETS
+SELECT * FROM t1 JOIN
+(
+    SELECT c3, c4 FROM t2
+    GROUP BY GROUPING SETS ((c3), (c4))
+) AS sub FOR KEY (c3, c4) -> t1 (c1, c2);
+ERROR:  key join from referencing relation sub to referenced relation t1 cannot be proven
+LINE 5: ) AS sub FOR KEY (c3, c4) -> t1 (c1, c2);
+                 ^
+DETAIL:  There is no matching foreign key constraint for sub (c3, c4) referencing t1 (c1, c2).
+-- rejected, reason: key join on subquery with SRF in target list
+SELECT * FROM t1 JOIN
+(
+    SELECT generate_series(1, 3) AS gs, c3, c4 FROM t2
+) AS sub FOR KEY (c3, c4) -> t1 (c1, c2);
+ERROR:  key join from referencing relation sub to referenced relation t1 cannot be proven
+LINE 4: ) AS sub FOR KEY (c3, c4) -> t1 (c1, c2);
+                 ^
+DETAIL:  There is no matching foreign key constraint for sub (c3, c4) referencing t1 (c1, c2).
+SELECT * FROM
+(
+    SELECT c1, c2 FROM t1 WHERE c2 > 0
+) AS u
+-- rejected, reason: referenced-side filter is not matched on the referencing side
+JOIN t2 FOR KEY (c3, c4) -> u (c1, c2);
+ERROR:  key join from referencing relation t2 to referenced relation u cannot be proven
+LINE 6: JOIN t2 FOR KEY (c3, c4) -> u (c1, c2);
+                ^
+DETAIL:  Not every t2 (c3, c4) value can be proven to have a matching u row. Referenced relation u is filtered before this key join.
+SELECT *
+FROM t1
+JOIN
+(
+    SELECT * FROM t10
+    JOIN t10 AS t10_2 FOR KEY (c23, c24) <- t10 (c25, c26)
+-- rejected, reason: derived referenced table exposes duplicate key column names
+) AS q1 FOR KEY (c23, c24) -> t1 (c1, c2);
+ERROR:  referencing column "c23" is ambiguous
+LINE 8: ) AS q1 FOR KEY (c23, c24) -> t1 (c1, c2);
+                ^
+SELECT *
+FROM t1
+JOIN
+(
+    SELECT
+        t10.c23,
+        t10.c24,
+        t10_2.c25,
+        t10_2.c26
+    FROM t10
+-- accepted
+    JOIN t10 AS t10_2 FOR KEY (c23, c24) <- t10 (c25, c26)
+-- rejected, reason: referencing column does not exist in the derived table
+) AS q1 FOR KEY (nonexistent, c24) -> t1 (c1, c2);
+ERROR:  referencing column "nonexistent" does not exist
+LINE 14: ) AS q1 FOR KEY (nonexistent, c24) -> t1 (c1, c2);
+                 ^
+SELECT *
+FROM t1
+JOIN
+(
+    SELECT
+        t10.c23,
+        t10_2.c24
+    FROM t10
+-- accepted
+    JOIN t10 AS t10_2 FOR KEY (c23, c24) <- t10 (c25, c26)
+-- rejected, reason: selected derived columns do not preserve the FK mapping
+) AS q1 FOR KEY (c23, c24) -> t1 (c1, c2);
+ERROR:  key join from referencing relation q1 to referenced relation t1 cannot be proven
+LINE 12: ) AS q1 FOR KEY (c23, c24) -> t1 (c1, c2);
+                 ^
+DETAIL:  There is no matching foreign key constraint for q1 (c23, c24) referencing t1 (c1, c2).
+--
+-- Test materialized views (not supported yet)
+--
+CREATE MATERIALIZED VIEW mv1 AS
+SELECT c1, c2 FROM t1;
+-- rejected, reason: materialized views do not expose surface FK facts
+SELECT * FROM mv1 JOIN t2 FOR KEY (c3, c4) -> mv1 (c1, c2);
+ERROR:  key join from referencing relation t2 to referenced relation mv1 cannot be proven
+LINE 1: SELECT * FROM mv1 JOIN t2 FOR KEY (c3, c4) -> mv1 (c1, c2);
+                                  ^
+DETAIL:  There is no matching foreign key constraint for t2 (c3, c4) referencing mv1 (c1, c2).
+DROP MATERIALIZED VIEW mv1;
+--
+-- Test PERIOD foreign keys (not key-join proof sources)
+--
+CREATE TABLE period_fk_parent
+(
+    id int4range,
+    valid_at daterange,
+    PRIMARY KEY (id, valid_at WITHOUT OVERLAPS),
+    UNIQUE (id, valid_at)
+);
+CREATE TABLE period_fk_child
+(
+    id int PRIMARY KEY,
+    parent_id int4range NOT NULL,
+    valid_at daterange NOT NULL,
+    FOREIGN KEY (parent_id, PERIOD valid_at)
+        REFERENCES period_fk_parent (id, PERIOD valid_at)
+);
+INSERT INTO period_fk_parent VALUES
+    ('[1,2)', daterange('2020-01-01', '2020-02-01')),
+    ('[1,2)', daterange('2020-02-01', '2020-03-01'));
+INSERT INTO period_fk_child VALUES
+    (1, '[1,2)', daterange('2020-01-15', '2020-02-15'));
+SELECT p.id, p.valid_at AS parent_period, c.id, c.valid_at AS child_period
+FROM period_fk_parent p
+-- rejected, reason: PERIOD FK can match multiple referenced periods
+JOIN period_fk_child c FOR KEY (parent_id, valid_at) -> p (id, valid_at);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 4: JOIN period_fk_child c FOR KEY (parent_id, valid_at) -> p (i...
+                               ^
+DETAIL:  There is no matching foreign key constraint for c (parent_id, valid_at) referencing p (id, valid_at).
+DROP TABLE period_fk_child, period_fk_parent;
+--
+-- Test regular inheritance tables
+--
+CREATE TABLE inh_parent_ref
+(
+    id int PRIMARY KEY
+);
+CREATE TABLE inh_child_ref () INHERITS (inh_parent_ref);
+CREATE TABLE inh_fk_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES inh_parent_ref(id)
+);
+INSERT INTO inh_parent_ref VALUES (1);
+INSERT INTO inh_child_ref VALUES (1);
+INSERT INTO inh_fk_child VALUES (1, 1);
+SELECT p.id, c.id
+FROM inh_parent_ref p
+-- rejected, reason: inherited parent scans are not supported for key joins
+JOIN inh_fk_child c FOR KEY (parent_id) -> p (id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 4: JOIN inh_fk_child c FOR KEY (parent_id) -> p (id);
+                            ^
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+SELECT p.id, c.id
+FROM ONLY inh_parent_ref p
+-- accepted
+JOIN inh_fk_child c FOR KEY (parent_id) -> p (id);
+ id | id 
+----+----
+  1 |  1
+(1 row)
+
+DROP TABLE inh_fk_child, inh_child_ref, inh_parent_ref;
+--
+-- Test partitioned tables
+--
+CREATE TABLE pt2
+(
+    c3 int not null,
+    c4 int not null,
+    CONSTRAINT pt2_pkey PRIMARY KEY (c3),
+    CONSTRAINT pt2_c3_fkey FOREIGN KEY (c3) REFERENCES t1 (c1)
+) PARTITION BY RANGE (c3);
+CREATE TABLE pt2_1 PARTITION OF pt2 FOR VALUES FROM (1) TO (3);
+CREATE TABLE pt2_2 PARTITION OF pt2 FOR VALUES FROM (3) TO (4);
+CREATE TABLE pt3
+(
+    c5 int not null,
+    c6 int not null,
+    CONSTRAINT pt3_pkey PRIMARY KEY (c5),
+    CONSTRAINT pt3_c5_fkey FOREIGN KEY (c5) REFERENCES pt2 (c3)
+) PARTITION BY RANGE (c5);
+CREATE TABLE pt3_1 PARTITION OF pt3 FOR VALUES FROM (1) TO (3);
+CREATE TABLE pt3_2 PARTITION OF pt3 FOR VALUES FROM (3) TO (4);
+INSERT INTO pt2 (c3, c4) VALUES (1, 100);
+INSERT INTO pt2 (c3, c4) VALUES (3, 300);
+INSERT INTO pt3 (c5, c6) VALUES (1, 1000);
+INSERT INTO pt3 (c5, c6) VALUES (3, 3000);
+-- accepted
+SELECT * FROM t1 JOIN pt2 FOR KEY (c3) -> t1 (c1) JOIN pt3 FOR KEY (c5) -> pt2 (c3);
+ c1 | c2 | c3 | c4  | c5 |  c6  
+----+----+----+-----+----+------
+  1 | 10 |  1 | 100 |  1 | 1000
+  3 | 30 |  3 | 300 |  3 | 3000
+(2 rows)
+
+-- accepted
+SELECT * FROM t1 JOIN pt2_1 FOR KEY (c3) -> t1 (c1);
+ c1 | c2 | c3 | c4  
+----+----+----+-----
+  1 | 10 |  1 | 100
+(1 row)
+
+-- rejected, reason: ONLY scans on partitioned tables are not supported
+SELECT * FROM ONLY pt2 JOIN pt3 FOR KEY (c5) -> pt2 (c3);
+ERROR:  key join from referencing relation pt3 to referenced relation pt2 cannot be proven
+LINE 1: SELECT * FROM ONLY pt2 JOIN pt3 FOR KEY (c5) -> pt2 (c3);
+                                        ^
+DETAIL:  There is no matching foreign key constraint for pt3 (c5) referencing pt2 (c3).
+DROP TABLE pt3;
+DROP TABLE pt2;
+SELECT *
+FROM (SELECT * FROM (SELECT c1, c2 FROM t1) AS q1(q1_c1, q1_c2)) q
+-- accepted
+JOIN t2 FOR KEY (c3, c4) -> q (q1_c1, q1_c2);
+ q1_c1 | q1_c2 | c3 | c4 
+-------+-------+----+----
+     1 |    10 |  1 | 10
+     3 |    30 |  3 | 30
+(2 rows)
+
+-- equivalent to:
+SELECT *
+FROM (WITH q1 (q1_c1, q1_c2) AS (SELECT c1, c2 FROM t1) SELECT * FROM q1) q
+-- accepted
+JOIN t2 FOR KEY (c3, c4) -> q (q1_c1, q1_c2);
+ q1_c1 | q1_c2 | c3 | c4 
+-------+-------+----+----
+     1 |    10 |  1 | 10
+     3 |    30 |  3 | 30
+(2 rows)
+
+DROP TABLE orders CASCADE;
+NOTICE:  drop cascades to view orders_by_country
+--
+-- The below query should raise the error "key join violation",
+-- "referenced relation does not preserve uniqueness of keys", since
+-- even though there is a UNIQUE constraint on orders.shipment_id,
+-- the orders table doesn't preserve the uniqueness of its keys
+-- due to the join with order_items.
+--
+CREATE TABLE shipments
+(
+    id INTEGER PRIMARY KEY,
+    carrier TEXT NOT NULL
+);
+CREATE TABLE orders
+(
+    id INTEGER PRIMARY KEY,
+    shipment_id INTEGER UNIQUE NULL REFERENCES shipments(id)
+);
+CREATE TABLE order_items
+(
+    id INTEGER PRIMARY KEY,
+    order_id INTEGER NOT NULL REFERENCES orders(id)
+);
+CREATE TABLE packages
+(
+    id INTEGER PRIMARY KEY,
+    shipment_id INTEGER NOT NULL REFERENCES shipments(id)
+);
+INSERT INTO shipments (id, carrier) VALUES
+  (100, 'UPS'),
+  (200, 'FedEx');
+INSERT INTO orders (id, shipment_id) VALUES
+  (10, 100),
+  (20, NULL);
+INSERT INTO order_items (id, order_id) VALUES
+  (1000, 10),
+  (1001, 10);
+INSERT INTO packages (id, shipment_id) VALUES
+  (900, 100);
+CREATE TABLE payments
+(
+    id INTEGER PRIMARY KEY,
+    order_id INTEGER NOT NULL REFERENCES orders(id),
+    amount NUMERIC NOT NULL,
+    method TEXT NOT NULL
+);
+INSERT INTO payments (id, order_id, amount, method) VALUES
+  (100, 10, 50.00, 'card'),
+  (200, 20, 29.99, 'card');
+--
+-- Inline fan trap: order_items and payments both join to orders via
+-- separate key joins.  The first join (order_items <- orders) consumes
+-- orders.id uniqueness, so the second join (payments -> orders) must
+-- be rejected — orders.id is no longer unique in the intermediate result.
+-- Wrapping the same joins in a view correctly rejects this; inline must
+-- behave identically.
+--
+SELECT *
+FROM order_items
+-- accepted
+JOIN orders FOR KEY (id) <- order_items (order_id)
+JOIN payments FOR KEY (order_id) -> orders (id);
+ERROR:  key join from referencing relation payments to referenced relation orders cannot be proven
+LINE 5: JOIN payments FOR KEY (order_id) -> orders (id);
+                      ^
+DETAIL:  Referenced columns orders (id) are not proven unique. A preceding join may duplicate rows from referenced relation orders.
+--
+-- Cross-type FK equality semantics are intentionally not supported by
+-- key-join proof.  The text UNIQUE constraint distinguishes 'a' and 'a ',
+-- but the FK to char(2) treats both as the same referenced key value.
+-- If accepted, the first LEFT key join would therefore duplicate p.id, and
+-- the outer key join through q.id must not use c.parent_id's text uniqueness
+-- to preserve p.id uniqueness.
+-- Supporting this would require carrying the distinction between FK
+-- equality and uniqueness equality through later output-fact projection.
+--
+CREATE TABLE eq_sem_parent
+(
+    id char(2) PRIMARY KEY
+);
+CREATE TABLE eq_sem_child
+(
+    id int PRIMARY KEY,
+    parent_id text NOT NULL UNIQUE REFERENCES eq_sem_parent (id)
+);
+CREATE TABLE eq_sem_ref
+(
+    id int PRIMARY KEY,
+    parent_id char(2) NOT NULL REFERENCES eq_sem_parent (id)
+);
+INSERT INTO eq_sem_parent VALUES ('a');
+INSERT INTO eq_sem_child VALUES (1, 'a'), (2, 'a ');
+INSERT INTO eq_sem_ref VALUES (1, 'a');
+SELECT p.id AS parent_id, c.id AS child_id, c.parent_id AS child_parent_id
+FROM eq_sem_parent p
+-- rejected, reason: FK and PK key types differ
+LEFT JOIN eq_sem_child c FOR KEY (parent_id) -> p (id)
+ORDER BY c.id;
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 4: LEFT JOIN eq_sem_child c FOR KEY (parent_id) -> p (id)
+                                 ^
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+SELECT q.id AS parent_id, r.id AS ref_id
+FROM (
+    SELECT p.id
+    FROM eq_sem_parent p
+-- rejected, reason: FK and PK key types differ
+    LEFT JOIN eq_sem_child c FOR KEY (parent_id) -> p (id)
+) q
+-- rejected, reason: inner cross-type key join is rejected first
+JOIN eq_sem_ref r FOR KEY (parent_id) -> q (id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 6:     LEFT JOIN eq_sem_child c FOR KEY (parent_id) -> p (id)
+                                     ^
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+DROP TABLE eq_sem_ref, eq_sem_child, eq_sem_parent;
+--
+-- Key-join proof rejects cross-type FK/PK key identity.  PostgreSQL accepts
+-- this FK, but key join deliberately does not compute proof facts from it.
+-- Supporting it would require proving FK containment and referenced-side
+-- uniqueness under different type-specific equality operators.
+--
+CREATE TABLE cross_type_key_parent
+(
+    id numeric PRIMARY KEY
+);
+CREATE TABLE cross_type_key_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES cross_type_key_parent (id)
+);
+INSERT INTO cross_type_key_parent VALUES (1);
+INSERT INTO cross_type_key_child VALUES (10, 1);
+SELECT p.id AS parent_id, c.id AS child_id, c.parent_id AS child_parent_id
+FROM cross_type_key_parent p
+-- rejected, reason: FK and PK key types differ
+JOIN cross_type_key_child c FOR KEY (parent_id) -> p (id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 4: JOIN cross_type_key_child c FOR KEY (parent_id) -> p (id);
+                                    ^
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+SELECT c.id AS child_id, p.id AS parent_id
+FROM cross_type_key_child c
+-- rejected, reason: FK and PK key types differ
+JOIN cross_type_key_parent p FOR KEY (id) <- c (parent_id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 4: JOIN cross_type_key_parent p FOR KEY (id) <- c (parent_id);
+                                     ^
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+DROP TABLE cross_type_key_child, cross_type_key_parent;
+--
+-- Key-join proof also requires exact typmod identity.
+-- Some typmod pairs may be safe, but proof does not reason about typmod
+-- coercion or typmod-specific equality behavior.
+--
+CREATE TABLE typmod_key_parent
+(
+    id numeric(10,0) PRIMARY KEY
+);
+CREATE TABLE typmod_key_child
+(
+    id int PRIMARY KEY,
+    parent_id numeric(10,2) NOT NULL REFERENCES typmod_key_parent (id)
+);
+INSERT INTO typmod_key_parent VALUES (1);
+INSERT INTO typmod_key_child VALUES (10, 1.00);
+SELECT p.id AS parent_id, c.id AS child_id
+FROM typmod_key_parent p
+-- rejected, reason: FK and PK typmods differ
+JOIN typmod_key_child c FOR KEY (parent_id) -> p (id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 4: JOIN typmod_key_child c FOR KEY (parent_id) -> p (id);
+                                ^
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+DROP TABLE typmod_key_child, typmod_key_parent;
+CREATE TABLE varchar_key_customers
+(
+    id varchar(128) PRIMARY KEY
+);
+CREATE TABLE varchar_key_orders
+(
+    customer_id varchar(128) NOT NULL REFERENCES varchar_key_customers (id)
+);
+INSERT INTO varchar_key_customers VALUES ('alice'), ('bob');
+INSERT INTO varchar_key_orders VALUES ('alice'), ('bob');
+SELECT *
+FROM varchar_key_customers
+JOIN varchar_key_orders FOR KEY (customer_id) -> varchar_key_customers (id)
+ORDER BY id, customer_id;
+  id   | customer_id 
+-------+-------------
+ alice | alice
+ bob   | bob
+(2 rows)
+
+SELECT *
+FROM varchar_key_orders
+JOIN varchar_key_customers FOR KEY (id) <- varchar_key_orders (customer_id)
+ORDER BY customer_id, id;
+ customer_id |  id   
+-------------+-------
+ alice       | alice
+ bob         | bob
+(2 rows)
+
+DROP TABLE varchar_key_orders, varchar_key_customers;
+CREATE TABLE varchar_typmod_key_parent
+(
+    id varchar(128) PRIMARY KEY
+);
+CREATE TABLE varchar_typmod_key_child
+(
+    parent_id varchar(64) NOT NULL REFERENCES varchar_typmod_key_parent (id)
+);
+INSERT INTO varchar_typmod_key_parent VALUES ('alice');
+INSERT INTO varchar_typmod_key_child VALUES ('alice');
+SELECT *
+FROM varchar_typmod_key_parent p
+-- rejected, reason: FK and PK typmods differ
+JOIN varchar_typmod_key_child c FOR KEY (parent_id) -> p (id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 4: JOIN varchar_typmod_key_child c FOR KEY (parent_id) -> p (id...
+                                        ^
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+DROP TABLE varchar_typmod_key_child, varchar_typmod_key_parent;
+--
+-- Key-join proof requires exact collation identity, even for two
+-- deterministic collations with equivalent behavior.
+-- Supporting this would require proving equivalence of collation semantics,
+-- not just checking that both collations are deterministic.
+--
+CREATE COLLATION key_join_c_copy FROM "C";
+CREATE TABLE collation_key_parent
+(
+    id text COLLATE "C" PRIMARY KEY
+);
+CREATE TABLE collation_key_child
+(
+    id int PRIMARY KEY,
+    parent_id text COLLATE key_join_c_copy NOT NULL REFERENCES collation_key_parent (id)
+);
+INSERT INTO collation_key_parent VALUES ('a');
+INSERT INTO collation_key_child VALUES (10, 'a');
+SELECT p.id AS parent_id, c.id AS child_id
+FROM collation_key_parent p
+-- rejected, reason: FK and PK collations differ
+JOIN collation_key_child c FOR KEY (parent_id) -> p (id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 4: JOIN collation_key_child c FOR KEY (parent_id) -> p (id);
+                                   ^
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+DROP TABLE collation_key_child, collation_key_parent;
+DROP COLLATION key_join_c_copy;
+--
+-- FK equality operators used for key-join proof must be immutable.  Temporal
+-- cross-type equality against timestamptz depends on TimeZone, so an FK row
+-- inserted under one TimeZone might not match the referenced row under
+-- another.
+--
+SET TimeZone = 'UTC';
+CREATE TABLE timezone_key_timestamp_parent
+(
+    id timestamp PRIMARY KEY
+);
+CREATE TABLE timezone_key_timestamp_child
+(
+    id int PRIMARY KEY,
+    parent_id timestamptz NOT NULL REFERENCES timezone_key_timestamp_parent (id)
+);
+INSERT INTO timezone_key_timestamp_parent VALUES
+    (TIMESTAMP '2020-01-01 00:00:00');
+INSERT INTO timezone_key_timestamp_child VALUES
+    (1, TIMESTAMPTZ '2020-01-01 00:00:00+00');
+CREATE TABLE timezone_key_date_parent
+(
+    id date PRIMARY KEY
+);
+CREATE TABLE timezone_key_date_child
+(
+    id int PRIMARY KEY,
+    parent_id timestamptz NOT NULL REFERENCES timezone_key_date_parent (id)
+);
+INSERT INTO timezone_key_date_parent VALUES (DATE '2020-01-01');
+INSERT INTO timezone_key_date_child VALUES
+    (1, TIMESTAMPTZ '2020-01-01 00:00:00+00');
+SET TimeZone = 'Europe/Stockholm';
+SELECT TIMESTAMP '2020-01-01 00:00:00' =
+       TIMESTAMPTZ '2020-01-01 00:00:00+00' AS timestamp_equality_now;
+ timestamp_equality_now 
+------------------------
+ f
+(1 row)
+
+SELECT DATE '2020-01-01' =
+       TIMESTAMPTZ '2020-01-01 00:00:00+00' AS date_equality_now;
+ date_equality_now 
+-------------------
+ f
+(1 row)
+
+SELECT c.id AS child_id, p.id AS parent_id
+FROM timezone_key_timestamp_child c
+-- rejected, reason: FK equality operator is not immutable
+JOIN timezone_key_timestamp_parent p FOR KEY (id) <- c (parent_id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 4: JOIN timezone_key_timestamp_parent p FOR KEY (id) <- c (pare...
+                                             ^
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+SELECT c.id AS child_id, p.id AS parent_id
+FROM timezone_key_date_child c
+-- rejected, reason: FK equality operator is not immutable
+JOIN timezone_key_date_parent p FOR KEY (id) <- c (parent_id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 4: JOIN timezone_key_date_parent p FOR KEY (id) <- c (parent_id...
+                                        ^
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+RESET TimeZone;
+DROP TABLE timezone_key_timestamp_child, timezone_key_timestamp_parent;
+DROP TABLE timezone_key_date_child, timezone_key_date_parent;
+--
+-- FK equality operators used for key-join proof must be strict.  Otherwise a
+-- nullable referencing row preserved by an outer key join can match multiple
+-- referenced rows.
+--
+CREATE FUNCTION key_join_nonstrict_eq(a int, b int) RETURNS boolean
+LANGUAGE sql IMMUTABLE AS
+$$ SELECT a IS NULL OR b IS NULL OR a = b $$;
+CREATE OPERATOR ===
+(
+    LEFTARG = int,
+    RIGHTARG = int,
+    PROCEDURE = key_join_nonstrict_eq,
+    COMMUTATOR = ===,
+    RESTRICT = eqsel,
+    JOIN = eqjoinsel,
+    MERGES
+);
+CREATE OPERATOR CLASS key_join_nonstrict_int4_ops
+FOR TYPE int USING btree AS
+    OPERATOR 1 < (int, int),
+    OPERATOR 2 <= (int, int),
+    OPERATOR 3 === (int, int),
+    OPERATOR 4 >= (int, int),
+    OPERATOR 5 > (int, int),
+    FUNCTION 1 btint4cmp(int, int);
+CREATE TABLE key_join_nonstrict_parent
+(
+    id int
+);
+CREATE UNIQUE INDEX key_join_nonstrict_parent_idx
+    ON key_join_nonstrict_parent
+    USING btree (id key_join_nonstrict_int4_ops);
+CREATE TABLE key_join_nonstrict_child
+(
+    id int PRIMARY KEY,
+    parent_id int REFERENCES key_join_nonstrict_parent (id)
+);
+INSERT INTO key_join_nonstrict_parent VALUES (1), (2);
+INSERT INTO key_join_nonstrict_child VALUES (10, NULL);
+SELECT c.id AS child_id, p.id AS parent_id
+FROM key_join_nonstrict_child c
+-- rejected, reason: FK equality operator is not strict
+LEFT JOIN key_join_nonstrict_parent p FOR KEY (id) <- c (parent_id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 4: LEFT JOIN key_join_nonstrict_parent p FOR KEY (id) <- c (par...
+                                              ^
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+DROP TABLE key_join_nonstrict_child,
+           key_join_nonstrict_parent;
+DROP OPERATOR CLASS key_join_nonstrict_int4_ops USING btree;
+DROP OPERATOR FAMILY key_join_nonstrict_int4_ops USING btree;
+DROP OPERATOR === (int, int);
+DROP FUNCTION key_join_nonstrict_eq(int, int);
+--
+-- FK equality operators used for key-join proof must match the normalized
+-- referenced-key equality operator.  A domain FK can use a cross-type equality
+-- operator that is valid for RI enforcement but not identical to the base-type
+-- equality member used for same-domain key joins.
+--
+CREATE DOMAIN key_join_ri_mismatch_dom AS int;
+CREATE FUNCTION key_join_ri_mismatch_dom_cmp(a key_join_ri_mismatch_dom,
+                                             b key_join_ri_mismatch_dom)
+RETURNS int LANGUAGE sql IMMUTABLE STRICT AS
+$$ SELECT btint4cmp(a::int, b::int) $$;
+CREATE FUNCTION key_join_ri_mismatch_dom_int_cmp(a key_join_ri_mismatch_dom,
+                                                 b int)
+RETURNS int LANGUAGE sql IMMUTABLE STRICT AS
+$$ SELECT btint4cmp(a::int, b) $$;
+CREATE FUNCTION key_join_ri_mismatch_int_dom_cmp(a int,
+                                                 b key_join_ri_mismatch_dom)
+RETURNS int LANGUAGE sql IMMUTABLE STRICT AS
+$$ SELECT btint4cmp(a, b::int) $$;
+CREATE FUNCTION key_join_ri_mismatch_dom_eq(a key_join_ri_mismatch_dom,
+                                            b key_join_ri_mismatch_dom)
+RETURNS boolean LANGUAGE sql IMMUTABLE STRICT AS
+$$ SELECT a::int = b::int $$;
+CREATE FUNCTION key_join_ri_mismatch_dom_int_eq(a key_join_ri_mismatch_dom,
+                                                b int)
+RETURNS boolean LANGUAGE sql IMMUTABLE STRICT AS
+$$ SELECT a::int = b $$;
+CREATE FUNCTION key_join_ri_mismatch_int_dom_eq(a int,
+                                                b key_join_ri_mismatch_dom)
+RETURNS boolean LANGUAGE sql IMMUTABLE STRICT AS
+$$ SELECT a = b::int $$;
+CREATE OPERATOR ===
+(
+    LEFTARG = key_join_ri_mismatch_dom,
+    RIGHTARG = key_join_ri_mismatch_dom,
+    PROCEDURE = key_join_ri_mismatch_dom_eq,
+    COMMUTATOR = ===,
+    RESTRICT = eqsel,
+    JOIN = eqjoinsel,
+    MERGES
+);
+CREATE OPERATOR ==#
+(
+    LEFTARG = key_join_ri_mismatch_dom,
+    RIGHTARG = int,
+    PROCEDURE = key_join_ri_mismatch_dom_int_eq,
+    COMMUTATOR = #==,
+    RESTRICT = eqsel,
+    JOIN = eqjoinsel,
+    MERGES
+);
+CREATE OPERATOR #==
+(
+    LEFTARG = int,
+    RIGHTARG = key_join_ri_mismatch_dom,
+    PROCEDURE = key_join_ri_mismatch_int_dom_eq,
+    COMMUTATOR = ==#,
+    RESTRICT = eqsel,
+    JOIN = eqjoinsel,
+    MERGES
+);
+CREATE OPERATOR FAMILY key_join_ri_mismatch_dom_ops USING btree;
+CREATE OPERATOR CLASS key_join_ri_mismatch_dom_ops
+FOR TYPE key_join_ri_mismatch_dom USING btree
+FAMILY key_join_ri_mismatch_dom_ops AS
+    OPERATOR 3 === (key_join_ri_mismatch_dom, key_join_ri_mismatch_dom),
+    FUNCTION 1 key_join_ri_mismatch_dom_cmp(key_join_ri_mismatch_dom,
+                                            key_join_ri_mismatch_dom);
+ALTER OPERATOR FAMILY key_join_ri_mismatch_dom_ops USING btree ADD
+    OPERATOR 3 ==# (key_join_ri_mismatch_dom, int),
+    OPERATOR 3 #== (int, key_join_ri_mismatch_dom),
+    OPERATOR 3 = (int, int),
+    FUNCTION 1 key_join_ri_mismatch_dom_int_cmp(key_join_ri_mismatch_dom, int),
+    FUNCTION 1 key_join_ri_mismatch_int_dom_cmp(int, key_join_ri_mismatch_dom),
+    FUNCTION 1 btint4cmp(int, int);
+CREATE TABLE key_join_ri_mismatch_parent
+(
+    id key_join_ri_mismatch_dom
+);
+CREATE UNIQUE INDEX key_join_ri_mismatch_parent_idx
+    ON key_join_ri_mismatch_parent
+    USING btree (id key_join_ri_mismatch_dom_ops);
+CREATE TABLE key_join_ri_mismatch_child
+(
+    id int PRIMARY KEY,
+    parent_id key_join_ri_mismatch_dom NOT NULL
+        REFERENCES key_join_ri_mismatch_parent (id)
+);
+INSERT INTO key_join_ri_mismatch_parent VALUES (1);
+INSERT INTO key_join_ri_mismatch_child VALUES (10, 1);
+SELECT c.id AS child_id, p.id AS parent_id
+FROM key_join_ri_mismatch_parent p
+-- rejected, reason: FK equality operator differs from referenced-key equality
+JOIN key_join_ri_mismatch_child c FOR KEY (parent_id) -> p (id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 4: JOIN key_join_ri_mismatch_child c FOR KEY (parent_id) -> p (...
+                                          ^
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+DROP TABLE key_join_ri_mismatch_child,
+           key_join_ri_mismatch_parent;
+DROP OPERATOR CLASS key_join_ri_mismatch_dom_ops USING btree;
+DROP OPERATOR FAMILY key_join_ri_mismatch_dom_ops USING btree;
+DROP OPERATOR === (key_join_ri_mismatch_dom, key_join_ri_mismatch_dom);
+DROP OPERATOR ==# (key_join_ri_mismatch_dom, int);
+DROP OPERATOR #== (int, key_join_ri_mismatch_dom);
+DROP FUNCTION key_join_ri_mismatch_dom_cmp(key_join_ri_mismatch_dom,
+                                           key_join_ri_mismatch_dom);
+DROP FUNCTION key_join_ri_mismatch_dom_int_cmp(key_join_ri_mismatch_dom, int);
+DROP FUNCTION key_join_ri_mismatch_int_dom_cmp(int, key_join_ri_mismatch_dom);
+DROP FUNCTION key_join_ri_mismatch_dom_eq(key_join_ri_mismatch_dom,
+                                          key_join_ri_mismatch_dom);
+DROP FUNCTION key_join_ri_mismatch_dom_int_eq(key_join_ri_mismatch_dom, int);
+DROP FUNCTION key_join_ri_mismatch_int_dom_eq(int, key_join_ri_mismatch_dom);
+DROP DOMAIN key_join_ri_mismatch_dom;
+--
+-- Key join trusts user-defined equality/operator-class contracts exactly as
+-- PostgreSQL trusts them for indexes and constraints.  This deliberately
+-- incoherent opclass is accepted because its catalog properties satisfy the
+-- proof boundary; key join does not attempt to prove operator semantics.
+--
+CREATE FUNCTION key_join_trusted_contract_eq(a int, b int) RETURNS boolean
+LANGUAGE sql IMMUTABLE STRICT AS $$ SELECT (a % 10) = (b % 10) $$;
+CREATE OPERATOR ===
+(
+    LEFTARG = int,
+    RIGHTARG = int,
+    PROCEDURE = key_join_trusted_contract_eq,
+    COMMUTATOR = ===,
+    RESTRICT = eqsel,
+    JOIN = eqjoinsel,
+    MERGES
+);
+CREATE OPERATOR CLASS key_join_trusted_contract_int4_ops
+FOR TYPE int USING btree AS
+    OPERATOR 1 < (int, int),
+    OPERATOR 2 <= (int, int),
+    OPERATOR 3 === (int, int),
+    OPERATOR 4 >= (int, int),
+    OPERATOR 5 > (int, int),
+    FUNCTION 1 btint4cmp(int, int);
+CREATE TABLE key_join_trusted_contract_parent
+(
+    id int NOT NULL
+);
+CREATE UNIQUE INDEX key_join_trusted_contract_parent_idx
+    ON key_join_trusted_contract_parent
+    USING btree (id key_join_trusted_contract_int4_ops);
+CREATE TABLE key_join_trusted_contract_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES key_join_trusted_contract_parent (id)
+);
+INSERT INTO key_join_trusted_contract_parent VALUES (1), (11);
+INSERT INTO key_join_trusted_contract_child VALUES (1, 1);
+SELECT c.id AS child_id, p.id AS parent_id
+FROM key_join_trusted_contract_child c
+JOIN key_join_trusted_contract_parent p FOR KEY (id) <- c (parent_id)
+ORDER BY parent_id;
+ child_id | parent_id 
+----------+-----------
+        1 |         1
+        1 |        11
+(2 rows)
+
+DROP TABLE key_join_trusted_contract_child,
+           key_join_trusted_contract_parent;
+DROP OPERATOR CLASS key_join_trusted_contract_int4_ops USING btree;
+DROP OPERATOR FAMILY key_join_trusted_contract_int4_ops USING btree;
+DROP OPERATOR === (int, int);
+DROP FUNCTION key_join_trusted_contract_eq(int, int);
+--
+-- Stored key joins must depend directly on FK equality operator functions.
+-- Otherwise CREATE OR REPLACE FUNCTION or ALTER FUNCTION could change an
+-- operator implementation after view creation without revalidating the
+-- stored proof.
+--
+CREATE FUNCTION key_join_operator_funcdep_eq(a int, b int) RETURNS boolean
+LANGUAGE sql IMMUTABLE STRICT AS $$ SELECT a = b $$;
+CREATE OPERATOR ===
+(
+    LEFTARG = int,
+    RIGHTARG = int,
+    PROCEDURE = key_join_operator_funcdep_eq,
+    COMMUTATOR = ===,
+    RESTRICT = eqsel,
+    JOIN = eqjoinsel,
+    MERGES
+);
+CREATE OPERATOR CLASS key_join_operator_funcdep_int4_ops
+FOR TYPE int USING btree AS
+    OPERATOR 1 < (int, int),
+    OPERATOR 2 <= (int, int),
+    OPERATOR 3 === (int, int),
+    OPERATOR 4 >= (int, int),
+    OPERATOR 5 > (int, int),
+    FUNCTION 1 btint4cmp(int, int);
+CREATE TABLE key_join_operator_funcdep_parent
+(
+    id int
+);
+CREATE UNIQUE INDEX key_join_operator_funcdep_parent_idx
+    ON key_join_operator_funcdep_parent
+    USING btree (id key_join_operator_funcdep_int4_ops);
+CREATE TABLE key_join_operator_funcdep_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES key_join_operator_funcdep_parent (id)
+);
+INSERT INTO key_join_operator_funcdep_parent VALUES (1);
+INSERT INTO key_join_operator_funcdep_child VALUES (1, 1);
+CREATE VIEW key_join_operator_funcdep_view AS
+SELECT c.id AS child_id, p.id AS parent_id
+FROM key_join_operator_funcdep_parent p
+JOIN key_join_operator_funcdep_child c FOR KEY (parent_id) -> p (id);
+SELECT count(*) AS funcdep_view_rows
+FROM key_join_operator_funcdep_view;
+ funcdep_view_rows 
+-------------------
+                 1
+(1 row)
+
+CREATE OR REPLACE FUNCTION key_join_operator_funcdep_eq(a int, b int)
+RETURNS boolean
+LANGUAGE sql STABLE STRICT AS $$ SELECT false $$;
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+SELECT provolatile AS funcdep_volatility
+FROM pg_proc
+WHERE oid = 'key_join_operator_funcdep_eq(int,int)'::regprocedure;
+ funcdep_volatility 
+--------------------
+ i
+(1 row)
+
+SELECT count(*) AS funcdep_view_rows
+FROM key_join_operator_funcdep_view;
+ funcdep_view_rows 
+-------------------
+                 1
+(1 row)
+
+ALTER FUNCTION key_join_operator_funcdep_eq(int, int) STABLE;
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+SELECT provolatile AS funcdep_volatility
+FROM pg_proc
+WHERE oid = 'key_join_operator_funcdep_eq(int,int)'::regprocedure;
+ funcdep_volatility 
+--------------------
+ i
+(1 row)
+
+SELECT count(*) AS funcdep_view_rows
+FROM key_join_operator_funcdep_view;
+ funcdep_view_rows 
+-------------------
+                 1
+(1 row)
+
+DROP VIEW key_join_operator_funcdep_view;
+DROP TABLE key_join_operator_funcdep_child,
+           key_join_operator_funcdep_parent;
+DROP OPERATOR CLASS key_join_operator_funcdep_int4_ops USING btree;
+DROP OPERATOR FAMILY key_join_operator_funcdep_int4_ops USING btree;
+DROP OPERATOR === (int, int);
+DROP FUNCTION key_join_operator_funcdep_eq(int, int);
+--
+-- Reused derived relation identities: two references to the same CTE or
+-- view body must remain distinct table occurrences.  The inner self-key join
+-- duplicates q.pid and drops id = 2 as a referenced key, so the outer key
+-- join through refs_to_nodes.ref_id must be rejected.
+--
+CREATE TABLE self_fk_nodes
+(
+    id INTEGER PRIMARY KEY,
+    parent_id INTEGER NOT NULL REFERENCES self_fk_nodes(id)
+);
+CREATE TABLE refs_to_nodes
+(
+    id INTEGER PRIMARY KEY,
+    ref_id INTEGER NOT NULL REFERENCES self_fk_nodes(id)
+);
+INSERT INTO self_fk_nodes VALUES (1, 1), (2, 1), (3, 1);
+INSERT INTO refs_to_nodes VALUES (1, 2);
+WITH n AS (SELECT id, parent_id FROM self_fk_nodes)
+SELECT *
+FROM (
+    SELECT p.id AS pid, c.id AS cid
+    FROM n p
+-- accepted
+    JOIN n c FOR KEY (parent_id) -> p (id)
+) q
+-- rejected, reason: inner self-key join duplicates the referenced key
+JOIN refs_to_nodes r FOR KEY (ref_id) -> q (pid);
+ERROR:  key join from referencing relation r to referenced relation q cannot be proven
+LINE 10: JOIN refs_to_nodes r FOR KEY (ref_id) -> q (pid);
+                              ^
+DETAIL:  Referenced columns q (pid) are not proven unique. A preceding join may duplicate rows from referenced relation q.
+CREATE VIEW self_fk_nodes_v AS
+SELECT id, parent_id FROM self_fk_nodes;
+SELECT *
+FROM (
+    SELECT p.id AS pid, c.id AS cid
+    FROM self_fk_nodes_v p
+-- accepted
+    JOIN self_fk_nodes_v c FOR KEY (parent_id) -> p (id)
+) q
+-- rejected, reason: inner self-key join duplicates the referenced key
+JOIN refs_to_nodes r FOR KEY (ref_id) -> q (pid);
+ERROR:  key join from referencing relation r to referenced relation q cannot be proven
+LINE 9: JOIN refs_to_nodes r FOR KEY (ref_id) -> q (pid);
+                             ^
+DETAIL:  Referenced columns q (pid) are not proven unique. A preceding join may duplicate rows from referenced relation q.
+SELECT *
+FROM (
+    SELECT *
+    FROM (
+        SELECT p.id AS pid, c.id AS cid
+        FROM self_fk_nodes_v p
+-- accepted
+        JOIN self_fk_nodes_v c FOR KEY (parent_id) -> p (id)
+    ) s
+) q
+-- rejected, reason: inner self-key join duplicates the referenced key
+JOIN refs_to_nodes r FOR KEY (ref_id) -> q (pid);
+ERROR:  key join from referencing relation r to referenced relation q cannot be proven
+LINE 12: JOIN refs_to_nodes r FOR KEY (ref_id) -> q (pid);
+                              ^
+DETAIL:  Referenced columns q (pid) are not proven unique. A preceding join may duplicate rows from referenced relation q.
+DROP VIEW self_fk_nodes_v;
+DROP TABLE refs_to_nodes;
+DROP TABLE self_fk_nodes;
+CREATE TABLE self_fk_nodes_valid
+(
+    id INTEGER PRIMARY KEY,
+    parent_id INTEGER NOT NULL UNIQUE REFERENCES self_fk_nodes_valid(id)
+);
+CREATE TABLE refs_to_nodes_valid
+(
+    id INTEGER PRIMARY KEY,
+    ref_id INTEGER NOT NULL REFERENCES self_fk_nodes_valid(id)
+);
+INSERT INTO self_fk_nodes_valid VALUES (1, 1), (2, 2), (3, 3);
+INSERT INTO refs_to_nodes_valid VALUES (1, 2);
+WITH n AS (SELECT id, parent_id FROM self_fk_nodes_valid)
+SELECT q.pid, q.cid, r.id AS rid
+FROM (
+    SELECT p.id AS pid, c.id AS cid
+    FROM n p
+-- accepted
+    LEFT JOIN n c FOR KEY (parent_id) -> p (id)
+) q
+JOIN refs_to_nodes_valid r FOR KEY (ref_id) -> q (pid)
+ORDER BY q.pid, q.cid, rid;
+ pid | cid | rid 
+-----+-----+-----
+   2 |   2 |   1
+(1 row)
+
+CREATE VIEW self_fk_nodes_valid_v AS
+SELECT id, parent_id FROM self_fk_nodes_valid;
+SELECT q.pid, q.cid, r.id AS rid
+FROM (
+    SELECT p.id AS pid, c.id AS cid
+    FROM self_fk_nodes_valid_v p
+-- accepted
+    LEFT JOIN self_fk_nodes_valid_v c FOR KEY (parent_id) -> p (id)
+) q
+JOIN refs_to_nodes_valid r FOR KEY (ref_id) -> q (pid)
+ORDER BY q.pid, q.cid, rid;
+ pid | cid | rid 
+-----+-----+-----
+   2 |   2 |   1
+(1 row)
+
+DROP VIEW self_fk_nodes_valid_v;
+DROP TABLE refs_to_nodes_valid;
+DROP TABLE self_fk_nodes_valid;
+--
+-- Correct pattern: pre-aggregate each child, then join through parent.
+-- Each derived table has one row per order_id (GROUP BY guarantees it),
+-- so both LEFT JOINs pass validation.
+--
+SELECT
+    o.id,
+    oi.item_count,
+    p.payment_total
+FROM orders o
+LEFT JOIN (
+    SELECT order_id, count(*) AS item_count
+    FROM order_items
+    GROUP BY order_id
+-- accepted
+) oi FOR KEY (order_id) -> o (id)
+LEFT JOIN (
+    SELECT order_id, SUM(amount) AS payment_total
+    FROM payments
+    GROUP BY order_id
+) p FOR KEY (order_id) -> o (id);
+ id | item_count | payment_total 
+----+------------+---------------
+ 10 |          2 |         50.00
+ 20 |            |         29.99
+(2 rows)
+
+DROP TABLE payments;
+--
+-- RLS-enabled relations do not expose key-join facts.  This is rejected
+-- even for the table owner/superuser because stored key-join proofs must
+-- not depend on role-specific RLS bypass behavior.
+--
+CREATE TABLE rls_key_join_parent
+(
+    id int PRIMARY KEY
+);
+CREATE TABLE rls_key_join_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES rls_key_join_parent (id)
+);
+ALTER TABLE rls_key_join_parent ENABLE ROW LEVEL SECURITY;
+SELECT *
+FROM rls_key_join_parent p
+JOIN rls_key_join_child c FOR KEY (parent_id) -> p (id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 3: JOIN rls_key_join_child c FOR KEY (parent_id) -> p (id);
+                                  ^
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+ALTER TABLE rls_key_join_parent DISABLE ROW LEVEL SECURITY;
+ALTER TABLE rls_key_join_child ENABLE ROW LEVEL SECURITY;
+SELECT *
+FROM rls_key_join_parent p
+JOIN rls_key_join_child c FOR KEY (parent_id) -> p (id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 3: JOIN rls_key_join_child c FOR KEY (parent_id) -> p (id);
+                                  ^
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+DROP TABLE rls_key_join_child, rls_key_join_parent;
+--
+--
+-- Test scalar functions alongside key joins.
+-- A scalar function (funcretset = false) returns exactly one row,
+-- so it has trivial uniqueness and row-preservation properties.
+--
+-- Scalar function call in SELECT list works fine with key joins
+CREATE FUNCTION scalar_meta() RETURNS int LANGUAGE sql STABLE AS $$ SELECT 42 $$;
+SELECT t1.c1, scalar_meta() AS meta, t2.c3
+FROM t1 JOIN t2 FOR KEY (c3) -> t1 (c1); -- accepted
+ c1 | meta | c3 
+----+------+----
+  1 |   42 |  1
+  3 |   42 |  3
+(2 rows)
+
+-- View with scalar function in SELECT list and key join in FROM
+CREATE VIEW v_fk_with_meta AS
+SELECT t1.c1, t1.c2, t2.c3, t2.c4, scalar_meta() AS meta
+-- accepted
+FROM t1 LEFT JOIN t2 FOR KEY (c3) -> t1 (c1);
+SELECT * FROM v_fk_with_meta; -- accepted
+ c1 | c2 | c3 | c4 | meta 
+----+----+----+----+------
+  1 | 10 |  1 | 10 |   42
+  2 | 20 |    |    |   42
+  3 | 30 |  3 | 30 |   42
+(3 rows)
+
+-- key join through the view (scalar in SELECT doesn't affect semantics)
+CREATE TABLE t_child_meta (
+    id int PRIMARY KEY,
+    parent_ref int NOT NULL REFERENCES t1 (c1)
+);
+INSERT INTO t_child_meta VALUES (100, 1), (200, 3);
+SELECT v.c1, v.meta, c.id
+FROM v_fk_with_meta v
+JOIN t_child_meta c FOR KEY (parent_ref) -> v (c1); -- accepted
+ c1 | meta | id  
+----+------+-----
+  1 |   42 | 100
+  3 |   42 | 200
+(2 rows)
+
+-- View wrapping a scalar function as sole FROM item cannot be
+-- an FK column source (drill_down_to_base_rel has no base table).
+CREATE FUNCTION scalar_row(OUT a int, OUT b int) RETURNS record
+LANGUAGE sql AS $$ SELECT 1, 42 $$;
+-- Scalar function as direct FK column source is rejected with clear error
+SELECT t1.c1, s.a
+FROM t1 JOIN scalar_row() AS s FOR KEY (a) -> t1 (c1); -- rejected, reason: functions
+ERROR:  key join from referencing relation s to referenced relation t1 cannot be proven
+LINE 2: FROM t1 JOIN scalar_row() AS s FOR KEY (a) -> t1 (c1);
+                                       ^
+DETAIL:  There is no matching foreign key constraint for s (a) referencing t1 (c1).
+-- View wrapping scalar function as FK column source
+CREATE VIEW v_scalar_only AS SELECT * FROM scalar_row();
+SELECT v.a, t2.c3
+FROM v_scalar_only v
+JOIN t2 FOR KEY (c3) -> v (a); -- rejected, reason: functions
+ERROR:  key join from referencing relation t2 to referenced relation v cannot be proven
+LINE 3: JOIN t2 FOR KEY (c3) -> v (a);
+                ^
+DETAIL:  There is no matching foreign key constraint for t2 (c3) referencing v (a).
+-- SRF as sole FROM item in a view used as FK column source
+CREATE FUNCTION srf_rows() RETURNS SETOF int LANGUAGE sql
+AS $$ SELECT generate_series(1, 3) $$;
+CREATE VIEW v_srf_only AS SELECT * FROM srf_rows() AS s(val);
+SELECT v.val, t2.c3
+FROM v_srf_only v
+JOIN t2 FOR KEY (c3) -> v (val); -- rejected, reason: set-returning functions
+ERROR:  key join from referencing relation t2 to referenced relation v cannot be proven
+LINE 3: JOIN t2 FOR KEY (c3) -> v (val);
+                ^
+DETAIL:  There is no matching foreign key constraint for t2 (c3) referencing v (val).
+-- SRF as direct FK column source
+SELECT t1.c1, s.val
+FROM t1 JOIN generate_series(1,3) AS s(val) FOR KEY (val) -> t1 (c1); -- rejected, reason: SRF
+ERROR:  key join from referencing relation s to referenced relation t1 cannot be proven
+LINE 2: FROM t1 JOIN generate_series(1,3) AS s(val) FOR KEY (val) ->...
+                                                    ^
+DETAIL:  There is no matching foreign key constraint for s (val) referencing t1 (c1).
+-- Cleanup
+DROP VIEW v_srf_only, v_scalar_only, v_fk_with_meta;
+DROP TABLE t_child_meta;
+DROP FUNCTION scalar_meta(), scalar_row(), srf_rows();
+-- rejected, reason: key join on data-modifying CTE
+WITH ins AS (INSERT INTO t2 (c3, c4) VALUES (1, 10) RETURNING c3, c4)
+SELECT * FROM t1 JOIN ins FOR KEY (c3, c4) -> t1 (c1, c2);
+ERROR:  key join from referencing relation ins to referenced relation t1 cannot be proven
+LINE 2: SELECT * FROM t1 JOIN ins FOR KEY (c3, c4) -> t1 (c1, c2);
+                                  ^
+DETAIL:  There is no matching foreign key constraint for ins (c3, c4) referencing t1 (c1, c2).
+-- ===================================================================
+-- Test: GROUP BY creates BUNC-set uniqueness (not just restores it)
+-- ===================================================================
+-- GROUP BY on non-unique FK columns should create uniqueness,
+-- allowing the result to be used as referencing side in a key join
+-- that preserves the referenced table's uniqueness.
+CREATE TABLE products_gbu (
+    id integer PRIMARY KEY
+);
+CREATE TABLE sales_gbu (
+    product_id integer NOT NULL REFERENCES products_gbu(id),
+    qty numeric NOT NULL,
+    price numeric NOT NULL
+);
+CREATE TABLE resupplies_gbu (
+    product_id integer NOT NULL REFERENCES products_gbu(id),
+    qty numeric NOT NULL,
+    price numeric NOT NULL
+);
+INSERT INTO products_gbu VALUES (1), (2), (3);
+INSERT INTO sales_gbu VALUES (1, 10, 5.00), (1, 20, 5.00), (2, 5, 10.00);
+INSERT INTO resupplies_gbu VALUES (1, 100, 4.00), (2, 50, 8.00);
+-- Basic: GROUP BY on non-unique FK column creates uniqueness in subquery,
+-- allowing key join from subquery to base table.
+CREATE VIEW v_sales_agg AS
+SELECT
+    p.id,
+    s.total_sold
+FROM products_gbu p
+LEFT JOIN (
+    SELECT product_id, SUM(price * qty) AS total_sold
+    FROM sales_gbu
+    GROUP BY product_id
+-- accepted
+) s FOR KEY (product_id) -> p (id);
+SELECT * FROM v_sales_agg ORDER BY id;
+ id | total_sold 
+----+------------
+  1 |     150.00
+  2 |      50.00
+  3 |           
+(3 rows)
+
+-- The view preserves products_gbu uniqueness, so outer key join works.
+SELECT * FROM v_sales_agg
+-- accepted
+JOIN resupplies_gbu FOR KEY (product_id) -> v_sales_agg (id)
+ORDER BY v_sales_agg.id;
+ id | total_sold | product_id | qty | price 
+----+------------+------------+-----+-------
+  1 |     150.00 |          1 | 100 |  4.00
+  2 |      50.00 |          2 |  50 |  8.00
+(2 rows)
+
+-- GROUP BY with more columns than FK: FK columns are a proper subset
+-- of GROUP BY columns, so FK columns are NOT unique.  The view can be
+-- created (the inner key join is fine because products_gbu is a base
+-- table), but using the view as a referenced side should error because
+-- products_gbu uniqueness is not preserved through the join.
+CREATE VIEW v_sales_multi_group AS
+SELECT
+    p.id,
+    s.total_qty
+FROM products_gbu p
+LEFT JOIN (
+    SELECT product_id, price, SUM(qty) AS total_qty
+    FROM sales_gbu
+    GROUP BY product_id, price
+-- accepted
+) s FOR KEY (product_id) -> p (id);
+SELECT * FROM v_sales_multi_group
+-- rejected, reason: grouped view does not preserve referenced-side uniqueness
+JOIN resupplies_gbu FOR KEY (product_id) -> v_sales_multi_group (id);
+ERROR:  key join from referencing relation resupplies_gbu to referenced relation v_sales_multi_group cannot be proven
+LINE 3: JOIN resupplies_gbu FOR KEY (product_id) -> v_sales_multi_gr...
+                            ^
+DETAIL:  Referenced columns v_sales_multi_group (id) are not proven unique. A preceding join may duplicate rows from referenced relation v_sales_multi_group. The relevant operation occurs inside view public.v_sales_multi_group.
+SELECT *
+FROM (
+    SELECT DISTINCT abs(id) AS id
+    FROM products_gbu
+) p
+-- rejected, reason: DISTINCT expression does not prove key uniqueness
+JOIN resupplies_gbu r FOR KEY (product_id) -> p (id);
+ERROR:  key join from referencing relation r to referenced relation p cannot be proven
+LINE 7: JOIN resupplies_gbu r FOR KEY (product_id) -> p (id);
+                              ^
+DETAIL:  There is no matching foreign key constraint for r (product_id) referencing p (id).
+-- Nondeterministic DISTINCT collations are exercised in key_join_icu.
+-- Query-level DISTINCT equality must use an immutable, strict operator.
+SET client_min_messages = warning;
+CREATE TYPE query_unique_nonstrict_type;
+CREATE FUNCTION query_unique_nonstrict_type_in(cstring)
+RETURNS query_unique_nonstrict_type
+LANGUAGE internal STRICT IMMUTABLE AS 'int4in';
+CREATE FUNCTION query_unique_nonstrict_type_out(query_unique_nonstrict_type)
+RETURNS cstring
+LANGUAGE internal STRICT IMMUTABLE AS 'int4out';
+CREATE TYPE query_unique_nonstrict_type (
+    input = query_unique_nonstrict_type_in,
+    output = query_unique_nonstrict_type_out,
+    like = int4
+);
+RESET client_min_messages;
+CREATE CAST (int4 AS query_unique_nonstrict_type) WITHOUT FUNCTION;
+CREATE CAST (query_unique_nonstrict_type AS int4) WITHOUT FUNCTION;
+CREATE FUNCTION query_unique_nonstrict_hash(query_unique_nonstrict_type)
+RETURNS int LANGUAGE internal STRICT IMMUTABLE AS 'hashint4';
+CREATE FUNCTION query_unique_nonstrict_eq(a query_unique_nonstrict_type,
+                                          b query_unique_nonstrict_type)
+RETURNS boolean LANGUAGE sql IMMUTABLE AS
+$$ SELECT a IS NULL OR b IS NULL OR a::int4 = b::int4 $$;
+CREATE OPERATOR === (
+    LEFTARG = query_unique_nonstrict_type,
+    RIGHTARG = query_unique_nonstrict_type,
+    PROCEDURE = query_unique_nonstrict_eq,
+    COMMUTATOR = ===,
+    RESTRICT = eqsel,
+    JOIN = eqjoinsel,
+    HASHES
+);
+CREATE OPERATOR CLASS query_unique_nonstrict_type_ops
+DEFAULT FOR TYPE query_unique_nonstrict_type USING hash AS
+    OPERATOR 1 === (query_unique_nonstrict_type,
+                    query_unique_nonstrict_type),
+    FUNCTION 1 query_unique_nonstrict_hash(query_unique_nonstrict_type);
+CREATE TABLE query_unique_nonstrict_parent (id query_unique_nonstrict_type);
+CREATE TABLE query_unique_nonstrict_child (parent_id query_unique_nonstrict_type);
+SELECT *
+FROM (SELECT DISTINCT id FROM query_unique_nonstrict_parent) p
+-- rejected, reason: DISTINCT equality operator is not strict
+JOIN query_unique_nonstrict_child c FOR KEY (parent_id) -> p (id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 4: JOIN query_unique_nonstrict_child c FOR KEY (parent_id) -> p...
+                                            ^
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+DROP TABLE query_unique_nonstrict_child, query_unique_nonstrict_parent;
+DROP OPERATOR CLASS query_unique_nonstrict_type_ops USING hash;
+DROP OPERATOR === (query_unique_nonstrict_type, query_unique_nonstrict_type);
+DROP FUNCTION query_unique_nonstrict_eq(query_unique_nonstrict_type,
+                                        query_unique_nonstrict_type);
+DROP FUNCTION query_unique_nonstrict_hash(query_unique_nonstrict_type);
+DROP CAST (query_unique_nonstrict_type AS int4);
+DROP CAST (int4 AS query_unique_nonstrict_type);
+SET client_min_messages = warning;
+DROP TYPE query_unique_nonstrict_type CASCADE;
+RESET client_min_messages;
+-- DISTINCT on non-unique FK column also creates uniqueness.
+CREATE VIEW v_sales_distinct AS
+SELECT
+    p.id,
+    s.product_id AS s_product_id
+FROM products_gbu p
+LEFT JOIN (
+    SELECT DISTINCT product_id
+    FROM sales_gbu
+-- accepted
+) s FOR KEY (product_id) -> p (id);
+SELECT * FROM v_sales_distinct ORDER BY id;
+ id | s_product_id 
+----+--------------
+  1 |            1
+  2 |            2
+  3 |             
+(3 rows)
+
+-- DISTINCT ON a non-key column can discard referenced keys, so it must not
+-- preserve the referenced relation's row set for a later key join.
+CREATE TABLE products_do (
+    id integer PRIMARY KEY,
+    grp integer NOT NULL
+);
+CREATE TABLE reviews_do (
+    id integer PRIMARY KEY,
+    product_id integer NOT NULL REFERENCES products_do(id)
+);
+INSERT INTO products_do VALUES (1, 10), (2, 10);
+INSERT INTO reviews_do VALUES (1, 1), (2, 2);
+SELECT q.id, r.id
+FROM (SELECT DISTINCT ON (grp) id FROM products_do ORDER BY grp, id) q
+-- rejected, reason: DISTINCT ON does not cover the referenced key
+JOIN reviews_do r FOR KEY (product_id) -> q (id);
+ERROR:  key join from referencing relation r to referenced relation q cannot be proven
+LINE 4: JOIN reviews_do r FOR KEY (product_id) -> q (id);
+                          ^
+DETAIL:  Not every r (product_id) value can be proven to have a matching q row. Referenced relation q may lose rows before this key join because of DISTINCT.
+DROP TABLE reviews_do, products_do;
+-- DISTINCT ON drops sibling rows for each distinct group, so a base
+-- rowCoverage on the wider key is no longer valid for the resulting
+-- surface.  An outer GROUP BY (or DISTINCT) on the wider key supplies a
+-- query-shape unique that, paired with the now-bogus catalog rowCoverage,
+-- would let a key join through and silently drop FK-bearing rows whose
+-- referenced row was dropped by the inner DISTINCT ON.
+CREATE TABLE rowcov_pk (a int NOT NULL, b int NOT NULL, PRIMARY KEY (a, b));
+CREATE TABLE rowcov_fk (fk_a int NOT NULL, fk_b int NOT NULL,
+                        FOREIGN KEY (fk_a, fk_b) REFERENCES rowcov_pk (a, b));
+INSERT INTO rowcov_pk VALUES (1, 1), (1, 2), (2, 1);
+INSERT INTO rowcov_fk VALUES (1, 2);
+SELECT *
+FROM (
+    SELECT a, b
+    FROM (SELECT DISTINCT ON (a) a, b FROM rowcov_pk ORDER BY a, b) i
+    GROUP BY a, b
+) sub
+-- rejected, reason: inner DISTINCT ON (a) drops sibling rows so (a, b)
+-- row coverage is lost regardless of the outer GROUP BY
+JOIN rowcov_fk FOR KEY (fk_a, fk_b) -> sub (a, b);
+ERROR:  key join from referencing relation rowcov_fk to referenced relation sub cannot be proven
+LINE 9: JOIN rowcov_fk FOR KEY (fk_a, fk_b) -> sub (a, b);
+                       ^
+DETAIL:  Not every rowcov_fk (fk_a, fk_b) value can be proven to have a matching sub row. Referenced relation sub may lose rows before this key join because of DISTINCT.
+SELECT *
+FROM (
+    SELECT DISTINCT a, b
+    FROM (SELECT DISTINCT ON (a) a, b FROM rowcov_pk ORDER BY a, b) i
+) sub
+-- rejected, reason: same anti-pattern using DISTINCT instead of GROUP BY
+JOIN rowcov_fk FOR KEY (fk_a, fk_b) -> sub (a, b);
+ERROR:  key join from referencing relation rowcov_fk to referenced relation sub cannot be proven
+LINE 7: JOIN rowcov_fk FOR KEY (fk_a, fk_b) -> sub (a, b);
+                       ^
+DETAIL:  Not every rowcov_fk (fk_a, fk_b) value can be proven to have a matching sub row. Referenced relation sub may lose rows before this key join because of DISTINCT.
+DROP TABLE rowcov_fk, rowcov_pk;
+-- GROUP BY/DISTINCT preserve row coverage only when their equality identity
+-- exactly matches the key equality identity.
+CREATE TABLE rowcov_exact_parent (id int PRIMARY KEY);
+CREATE TABLE rowcov_exact_child (
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES rowcov_exact_parent (id)
+);
+INSERT INTO rowcov_exact_parent VALUES (1), (2);
+INSERT INTO rowcov_exact_child VALUES (10, 1), (20, 2);
+SELECT p.id, c.id AS child_id
+FROM (SELECT id FROM rowcov_exact_parent GROUP BY id) p
+-- accepted
+JOIN rowcov_exact_child c FOR KEY (parent_id) -> p (id)
+ORDER BY p.id, c.id;
+ id | child_id 
+----+----------
+  1 |       10
+  2 |       20
+(2 rows)
+
+SELECT p.id, c.id AS child_id
+FROM (SELECT DISTINCT id FROM rowcov_exact_parent) p
+-- accepted
+JOIN rowcov_exact_child c FOR KEY (parent_id) -> p (id)
+ORDER BY p.id, c.id;
+ id | child_id 
+----+----------
+  1 |       10
+  2 |       20
+(2 rows)
+
+DROP TABLE rowcov_exact_child, rowcov_exact_parent;
+-- GROUP BY/DISTINCT preserve row coverage for nullable referenced keys when
+-- the collapsed key covers the same all-non-null values under the same key
+-- identity.  Null-containing referenced rows are outside the referenced
+-- multiset that key joins must cover.
+CREATE TABLE rowcov_nullable_parent (
+    id int PRIMARY KEY,
+    code text UNIQUE
+);
+CREATE TABLE rowcov_nullable_child (
+    id int PRIMARY KEY,
+    parent_code text NOT NULL REFERENCES rowcov_nullable_parent (code)
+);
+INSERT INTO rowcov_nullable_parent VALUES
+    (1, 'a'), (2, 'b'), (3, NULL), (4, NULL);
+INSERT INTO rowcov_nullable_child VALUES (10, 'a'), (20, 'b');
+SELECT p.code, c.id AS child_id
+FROM (SELECT code FROM rowcov_nullable_parent GROUP BY code) p
+-- accepted
+JOIN rowcov_nullable_child c FOR KEY (parent_code) -> p (code)
+ORDER BY p.code, c.id;
+ code | child_id 
+------+----------
+ a    |       10
+ b    |       20
+(2 rows)
+
+SELECT p.code, c.id AS child_id
+FROM (SELECT DISTINCT code FROM rowcov_nullable_parent) p
+-- accepted
+JOIN rowcov_nullable_child c FOR KEY (parent_code) -> p (code)
+ORDER BY p.code, c.id;
+ code | child_id 
+------+----------
+ a    |       10
+ b    |       20
+(2 rows)
+
+SELECT p.code, c.id AS child_id
+FROM (SELECT code FROM rowcov_nullable_parent GROUP BY code HAVING count(*) > 0) p
+-- rejected, reason: HAVING remains a row-removal barrier
+JOIN rowcov_nullable_child c FOR KEY (parent_code) -> p (code);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 4: JOIN rowcov_nullable_child c FOR KEY (parent_code) -> p (cod...
+                                     ^
+DETAIL:  Not every c (parent_code) value can be proven to have a matching p row. Referenced relation p may lose rows before this key join because of HAVING, LIMIT, OFFSET, FOR UPDATE or TABLESAMPLE.
+DROP TABLE rowcov_nullable_child, rowcov_nullable_parent;
+-- Extra row-collapse columns may have different identity from the key.
+-- Row coverage is preserved as long as the grouped key column itself has
+-- the exact key identity.
+CREATE TABLE rowcov_identity_type_parent (
+    label text NOT NULL,
+    id int PRIMARY KEY
+);
+CREATE TABLE rowcov_identity_type_child (
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES rowcov_identity_type_parent (id)
+);
+INSERT INTO rowcov_identity_type_parent VALUES ('a', 1), ('b', 2);
+INSERT INTO rowcov_identity_type_child VALUES (10, 1), (20, 2);
+SELECT p.id, c.id AS child_id
+FROM (
+    SELECT label, id
+    FROM rowcov_identity_type_parent
+    GROUP BY label, id
+) p
+-- accepted, reason: extra grouped column has different type
+JOIN rowcov_identity_type_child c FOR KEY (parent_id) -> p (id)
+ORDER BY p.id, c.id;
+ id | child_id 
+----+----------
+  1 |       10
+  2 |       20
+(2 rows)
+
+DROP TABLE rowcov_identity_type_child, rowcov_identity_type_parent;
+CREATE TABLE rowcov_identity_typmod_parent (
+    amount numeric(5,0) NOT NULL,
+    id numeric(10,0) PRIMARY KEY
+);
+CREATE TABLE rowcov_identity_typmod_child (
+    id int PRIMARY KEY,
+    parent_id numeric(10,0) NOT NULL
+        REFERENCES rowcov_identity_typmod_parent (id)
+);
+INSERT INTO rowcov_identity_typmod_parent VALUES (1, 10), (2, 20);
+INSERT INTO rowcov_identity_typmod_child VALUES (10, 10), (20, 20);
+SELECT p.id, c.id AS child_id
+FROM (
+    SELECT amount, id
+    FROM rowcov_identity_typmod_parent
+    GROUP BY amount, id
+) p
+-- accepted, reason: extra grouped column has different typmod
+JOIN rowcov_identity_typmod_child c FOR KEY (parent_id) -> p (id)
+ORDER BY p.id, c.id;
+ id | child_id 
+----+----------
+ 10 |       10
+ 20 |       20
+(2 rows)
+
+DROP TABLE rowcov_identity_typmod_child, rowcov_identity_typmod_parent;
+CREATE TABLE rowcov_identity_collation_parent (
+    label text COLLATE "POSIX" NOT NULL,
+    id text COLLATE "C" PRIMARY KEY
+);
+CREATE TABLE rowcov_identity_collation_child (
+    id int PRIMARY KEY,
+    parent_id text COLLATE "C" NOT NULL
+        REFERENCES rowcov_identity_collation_parent (id)
+);
+INSERT INTO rowcov_identity_collation_parent VALUES ('a', 'x'), ('b', 'y');
+INSERT INTO rowcov_identity_collation_child VALUES (10, 'x'), (20, 'y');
+SELECT p.id, c.id AS child_id
+FROM (
+    SELECT label, id
+    FROM rowcov_identity_collation_parent
+    GROUP BY label, id
+) p
+-- accepted, reason: extra grouped column has different collation
+JOIN rowcov_identity_collation_child c FOR KEY (parent_id) -> p (id)
+ORDER BY p.id, c.id;
+ id | child_id 
+----+----------
+ x  |       10
+ y  |       20
+(2 rows)
+
+DROP TABLE rowcov_identity_collation_child, rowcov_identity_collation_parent;
+CREATE FUNCTION rowcov_numeric_scale_cmp(a numeric, b numeric) RETURNS int
+LANGUAGE sql IMMUTABLE STRICT AS
+$$ SELECT CASE WHEN a < b THEN -1
+               WHEN a > b THEN 1
+               WHEN scale(a) < scale(b) THEN -1
+               WHEN scale(a) > scale(b) THEN 1
+               ELSE 0 END $$;
+CREATE FUNCTION rowcov_numeric_scale_eq(a numeric, b numeric) RETURNS boolean
+LANGUAGE sql IMMUTABLE STRICT AS $$ SELECT a = b AND scale(a) = scale(b) $$;
+CREATE FUNCTION rowcov_numeric_scale_lt(a numeric, b numeric) RETURNS boolean
+LANGUAGE sql IMMUTABLE STRICT AS
+$$ SELECT rowcov_numeric_scale_cmp(a, b) < 0 $$;
+CREATE OPERATOR =# (
+    LEFTARG = numeric,
+    RIGHTARG = numeric,
+    FUNCTION = rowcov_numeric_scale_eq,
+    COMMUTATOR = =#,
+    RESTRICT = eqsel,
+    JOIN = eqjoinsel,
+    MERGES
+);
+CREATE OPERATOR <# (
+    LEFTARG = numeric,
+    RIGHTARG = numeric,
+    FUNCTION = rowcov_numeric_scale_lt
+);
+CREATE OPERATOR CLASS rowcov_numeric_scale_ops
+FOR TYPE numeric USING btree AS
+    OPERATOR 1 <# (numeric, numeric),
+    OPERATOR 3 =# (numeric, numeric),
+    FUNCTION 1 rowcov_numeric_scale_cmp(numeric, numeric);
+CREATE TABLE rowcov_numeric_parent (id numeric NOT NULL);
+CREATE UNIQUE INDEX rowcov_numeric_parent_id_idx
+    ON rowcov_numeric_parent USING btree (id rowcov_numeric_scale_ops);
+CREATE TABLE rowcov_numeric_child (
+    id int PRIMARY KEY,
+    parent_id numeric NOT NULL REFERENCES rowcov_numeric_parent (id)
+);
+INSERT INTO rowcov_numeric_parent VALUES (1.0), (1.00);
+INSERT INTO rowcov_numeric_child VALUES (10, 1.00);
+SELECT *
+FROM (SELECT id FROM rowcov_numeric_parent GROUP BY id) p
+-- rejected, reason: GROUP BY equality collapses keys distinct under FK equality
+JOIN rowcov_numeric_child c FOR KEY (parent_id) -> p (id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 4: JOIN rowcov_numeric_child c FOR KEY (parent_id) -> p (id);
+                                    ^
+DETAIL:  Not every c (parent_id) value can be proven to have a matching p row. Referenced relation p may lose rows before this key join because of GROUP BY.
+SELECT *
+FROM (SELECT DISTINCT id FROM rowcov_numeric_parent) p
+-- rejected, reason: DISTINCT equality collapses keys distinct under FK equality
+JOIN rowcov_numeric_child c FOR KEY (parent_id) -> p (id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 4: JOIN rowcov_numeric_child c FOR KEY (parent_id) -> p (id);
+                                    ^
+DETAIL:  Not every c (parent_id) value can be proven to have a matching p row. Referenced relation p may lose rows before this key join because of DISTINCT.
+SELECT *
+FROM (
+    SELECT DISTINCT ON (id) id
+    FROM rowcov_numeric_parent
+    ORDER BY id, id USING <#
+) p
+-- rejected, reason: duplicate DISTINCT ON ordering adds no key coverage
+JOIN rowcov_numeric_child c FOR KEY (parent_id) -> p (id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 8: JOIN rowcov_numeric_child c FOR KEY (parent_id) -> p (id);
+                                    ^
+DETAIL:  Not every c (parent_id) value can be proven to have a matching p row. Referenced relation p may lose rows before this key join because of DISTINCT.
+CREATE VIEW rowcov_numeric_group_v AS
+SELECT id FROM rowcov_numeric_parent GROUP BY id;
+SELECT *
+FROM rowcov_numeric_group_v p
+-- rejected, reason: GROUP BY row coverage loss occurred inside the view
+JOIN rowcov_numeric_child c FOR KEY (parent_id) -> p (id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 4: JOIN rowcov_numeric_child c FOR KEY (parent_id) -> p (id);
+                                    ^
+DETAIL:  Not every c (parent_id) value can be proven to have a matching p row. Referenced relation p may lose rows before this key join because of GROUP BY. The relevant operation occurs inside view public.rowcov_numeric_group_v.
+DROP VIEW rowcov_numeric_group_v;
+CREATE VIEW rowcov_numeric_distinct_v AS
+SELECT DISTINCT id FROM rowcov_numeric_parent;
+SELECT *
+FROM rowcov_numeric_distinct_v p
+-- rejected, reason: DISTINCT row coverage loss occurred inside the view
+JOIN rowcov_numeric_child c FOR KEY (parent_id) -> p (id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 4: JOIN rowcov_numeric_child c FOR KEY (parent_id) -> p (id);
+                                    ^
+DETAIL:  Not every c (parent_id) value can be proven to have a matching p row. Referenced relation p may lose rows before this key join because of DISTINCT. The relevant operation occurs inside view public.rowcov_numeric_distinct_v.
+DROP VIEW rowcov_numeric_distinct_v;
+DROP TABLE rowcov_numeric_child, rowcov_numeric_parent;
+DROP OPERATOR CLASS rowcov_numeric_scale_ops USING btree;
+DROP OPERATOR FAMILY rowcov_numeric_scale_ops USING btree;
+DROP OPERATOR <# (numeric, numeric);
+DROP OPERATOR =# (numeric, numeric);
+DROP FUNCTION rowcov_numeric_scale_cmp(numeric, numeric);
+DROP FUNCTION rowcov_numeric_scale_eq(numeric, numeric);
+DROP FUNCTION rowcov_numeric_scale_lt(numeric, numeric);
+CREATE SEQUENCE volatile_default_seq;
+CREATE FUNCTION volatile_default_int(v int DEFAULT nextval('volatile_default_seq')::int)
+RETURNS int
+LANGUAGE sql STABLE AS $$ SELECT v $$;
+CREATE VIEW volatile_target_parent_v AS
+SELECT id, set_config('key_join.volatile', id::text, false) AS side_effect
+FROM products_gbu;
+SELECT *
+FROM volatile_target_parent_v p
+-- rejected, reason: volatile targetlist blocks surface row coverage
+JOIN resupplies_gbu r FOR KEY (product_id) -> p (id);
+ERROR:  key join from referencing relation r to referenced relation p cannot be proven
+LINE 4: JOIN resupplies_gbu r FOR KEY (product_id) -> p (id);
+                              ^
+DETAIL:  There is no matching foreign key constraint for r (product_id) referencing p (id).
+DROP VIEW volatile_target_parent_v;
+CREATE VIEW volatile_default_target_parent_v AS
+SELECT id, volatile_default_int() AS side_effect
+FROM products_gbu;
+SELECT *
+FROM volatile_default_target_parent_v p
+-- rejected, reason: volatile default targetlist blocks surface row coverage
+JOIN resupplies_gbu r FOR KEY (product_id) -> p (id);
+ERROR:  key join from referencing relation r to referenced relation p cannot be proven
+LINE 4: JOIN resupplies_gbu r FOR KEY (product_id) -> p (id);
+                              ^
+DETAIL:  There is no matching foreign key constraint for r (product_id) referencing p (id).
+DROP VIEW volatile_default_target_parent_v;
+DROP FUNCTION volatile_default_int(int);
+DROP SEQUENCE volatile_default_seq;
+-- Cleanup
+DROP VIEW v_sales_agg, v_sales_multi_group, v_sales_distinct;
+DROP TABLE resupplies_gbu, sales_gbu, products_gbu CASCADE;
+-- ============================================================
+-- FILTER clause tests
+-- ============================================================
+-- FILTER1: key join with FILTER clause — restricts result to t2.c4 = 10
+SELECT * FROM t1
+-- accepted
+  JOIN t2 FOR KEY (c3) -> t1 (c1)
+  FILTER (WHERE t2.c4 = 10);
+ c1 | c2 | c3 | c4 
+----+----+----+----
+  1 | 10 |  1 | 10
+(1 row)
+
+CREATE TABLE scalar_array_filter_parent (id boolean PRIMARY KEY);
+CREATE TABLE scalar_array_filter_child
+(
+    id int PRIMARY KEY,
+    parent_id boolean NOT NULL REFERENCES scalar_array_filter_parent (id)
+);
+INSERT INTO scalar_array_filter_parent VALUES (false), (true);
+INSERT INTO scalar_array_filter_child VALUES (1, true), (2, false);
+CREATE VIEW scalar_array_filter_parent_v AS
+SELECT id
+FROM scalar_array_filter_parent
+WHERE id = (true = ANY (ARRAY[false, length('x') = 1]));
+CREATE VIEW scalar_array_filter_child_v AS
+SELECT parent_id
+FROM scalar_array_filter_child
+WHERE parent_id = (true = ANY (ARRAY[false, length('x') = 1]));
+SELECT count(*) AS scalar_array_filter_rows
+FROM scalar_array_filter_parent_v p
+-- rejected, reason: ScalarArrayOpExpr filter values are not proof-filter terms
+JOIN scalar_array_filter_child_v c FOR KEY (parent_id) -> p (id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 4: JOIN scalar_array_filter_child_v c FOR KEY (parent_id) -> p ...
+                                           ^
+DETAIL:  Not every c (parent_id) value can be proven to have a matching p row. Referenced relation p is filtered before this key join. The relevant operation occurs inside view public.scalar_array_filter_parent_v.
+DROP VIEW scalar_array_filter_child_v, scalar_array_filter_parent_v;
+DROP TABLE scalar_array_filter_child, scalar_array_filter_parent;
+-- FILTER1b: strict proof-filter value allowlist accepts constants
+CREATE TABLE filter_const_parent (id int PRIMARY KEY);
+CREATE TABLE filter_const_child
+(
+    parent_id int NOT NULL REFERENCES filter_const_parent (id)
+);
+INSERT INTO filter_const_parent VALUES (1), (2);
+INSERT INTO filter_const_child VALUES (1);
+CREATE VIEW filter_const_parent_v AS
+SELECT id FROM filter_const_parent WHERE id = 1::integer;
+CREATE VIEW filter_const_child_v AS
+SELECT parent_id FROM filter_const_child WHERE parent_id = 1::integer;
+SELECT count(*) AS const_filter_rows
+FROM filter_const_parent_v p
+JOIN filter_const_child_v c FOR KEY (parent_id) -> p (id);
+ const_filter_rows 
+-------------------
+                 1
+(1 row)
+
+DROP VIEW filter_const_child_v, filter_const_parent_v;
+DROP TABLE filter_const_child, filter_const_parent;
+-- FILTER1c: strict proof-filter value allowlist accepts SQL value functions
+CREATE TABLE filter_sqlvalue_parent (id name PRIMARY KEY);
+CREATE TABLE filter_sqlvalue_child
+(
+    parent_id name NOT NULL REFERENCES filter_sqlvalue_parent (id)
+);
+INSERT INTO filter_sqlvalue_parent VALUES (CURRENT_USER);
+INSERT INTO filter_sqlvalue_child VALUES (CURRENT_USER);
+CREATE VIEW filter_sqlvalue_parent_v AS
+SELECT id FROM filter_sqlvalue_parent WHERE id = CURRENT_USER;
+CREATE VIEW filter_sqlvalue_child_v AS
+SELECT parent_id FROM filter_sqlvalue_child WHERE parent_id = CURRENT_USER;
+SELECT count(*) AS sqlvalue_filter_rows
+FROM filter_sqlvalue_parent_v p
+JOIN filter_sqlvalue_child_v c FOR KEY (parent_id) -> p (id);
+ sqlvalue_filter_rows 
+----------------------
+                    1
+(1 row)
+
+DROP VIEW filter_sqlvalue_child_v, filter_sqlvalue_parent_v;
+DROP TABLE filter_sqlvalue_child, filter_sqlvalue_parent;
+-- FILTER1c-2: SQL value function filters on varchar keys use text = name
+CREATE TABLE filter_varchar_tenant_customers
+(
+    tenant_id varchar(128),
+    id int,
+    name varchar(100),
+    PRIMARY KEY (tenant_id, id)
+);
+CREATE TABLE filter_varchar_tenant_orders
+(
+    tenant_id varchar(128),
+    id int,
+    customer_id int NOT NULL,
+    amount numeric(10,2),
+    PRIMARY KEY (tenant_id, id),
+    FOREIGN KEY (tenant_id, customer_id)
+        REFERENCES filter_varchar_tenant_customers (tenant_id, id)
+);
+INSERT INTO filter_varchar_tenant_customers
+VALUES (CURRENT_USER, 1, 'Alice'), ('other_tenant', 1, 'Other');
+INSERT INTO filter_varchar_tenant_orders
+VALUES (CURRENT_USER, 10, 1, 5.00), ('other_tenant', 20, 1, 7.00);
+CREATE VIEW filter_varchar_tenant_customers_v AS
+SELECT * FROM filter_varchar_tenant_customers
+WHERE tenant_id = CURRENT_USER;
+CREATE VIEW filter_varchar_tenant_orders_v AS
+SELECT * FROM filter_varchar_tenant_orders
+WHERE tenant_id = CURRENT_USER;
+SELECT o.id, c.name
+FROM filter_varchar_tenant_orders_v o
+JOIN filter_varchar_tenant_customers_v c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id)
+ORDER BY o.id, c.name;
+ id | name  
+----+-------
+ 10 | Alice
+(1 row)
+
+SELECT count(*) AS varchar_sqlvalue_filter_unmatched_rows
+FROM filter_varchar_tenant_orders o
+-- rejected, reason: the referenced-side CURRENT_USER filter is not matched on the FK side
+JOIN filter_varchar_tenant_customers_v c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+ERROR:  key join from referencing relation o to referenced relation c cannot be proven
+LINE 4: JOIN filter_varchar_tenant_customers_v c FOR KEY (tenant_id,...
+                                                 ^
+DETAIL:  Not every o (tenant_id, customer_id) value can be proven to have a matching c row. Referenced relation c has a filter that is not matched by referencing relation o.
+DROP VIEW filter_varchar_tenant_orders_v, filter_varchar_tenant_customers_v;
+DROP TABLE filter_varchar_tenant_orders, filter_varchar_tenant_customers;
+-- FILTER1d: strict proof-filter value allowlist accepts relabel casts
+CREATE TABLE filter_relabel_parent (id text PRIMARY KEY);
+CREATE TABLE filter_relabel_child
+(
+    parent_id text NOT NULL REFERENCES filter_relabel_parent (id)
+);
+INSERT INTO filter_relabel_parent VALUES ('a'), ('b');
+INSERT INTO filter_relabel_child VALUES ('a');
+CREATE VIEW filter_relabel_parent_v AS
+SELECT id FROM filter_relabel_parent
+WHERE id = ('a'::varchar)::text;
+CREATE VIEW filter_relabel_child_v AS
+SELECT parent_id FROM filter_relabel_child
+WHERE parent_id = ('a'::varchar)::text;
+SELECT count(*) AS relabel_filter_rows
+FROM filter_relabel_parent_v p
+JOIN filter_relabel_child_v c FOR KEY (parent_id) -> p (id);
+ relabel_filter_rows 
+---------------------
+                   1
+(1 row)
+
+DROP VIEW filter_relabel_child_v, filter_relabel_parent_v;
+DROP TABLE filter_relabel_child, filter_relabel_parent;
+-- FILTER1e: strict proof-filter value allowlist accepts stable functions
+CREATE FUNCTION filter_stable_identity(v int) RETURNS int
+LANGUAGE sql STABLE AS $$ SELECT v $$;
+CREATE TABLE filter_func_parent (id int PRIMARY KEY);
+CREATE TABLE filter_func_child
+(
+    parent_id int NOT NULL REFERENCES filter_func_parent (id)
+);
+INSERT INTO filter_func_parent VALUES (1), (2);
+INSERT INTO filter_func_child VALUES (1);
+CREATE VIEW filter_func_parent_v AS
+SELECT id FROM filter_func_parent WHERE id = filter_stable_identity(1);
+CREATE VIEW filter_func_child_v AS
+SELECT parent_id FROM filter_func_child WHERE parent_id = filter_stable_identity(1);
+SELECT count(*) AS func_filter_rows
+FROM filter_func_parent_v p
+JOIN filter_func_child_v c FOR KEY (parent_id) -> p (id);
+ func_filter_rows 
+------------------
+                1
+(1 row)
+
+DROP VIEW filter_func_child_v, filter_func_parent_v;
+CREATE VIEW filter_func_arg_parent_v AS
+SELECT id FROM filter_func_parent
+WHERE id = filter_stable_identity(LEAST(1, 2));
+CREATE VIEW filter_func_arg_child_v AS
+SELECT parent_id FROM filter_func_child
+WHERE parent_id = filter_stable_identity(LEAST(1, 2));
+SELECT count(*) AS func_arg_filter_rows
+FROM filter_func_arg_parent_v p
+-- rejected, reason: function arguments must be proof-filter terms
+JOIN filter_func_arg_child_v c FOR KEY (parent_id) -> p (id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 4: JOIN filter_func_arg_child_v c FOR KEY (parent_id) -> p (id)...
+                                       ^
+DETAIL:  Not every c (parent_id) value can be proven to have a matching p row. Referenced relation p is filtered before this key join. The relevant operation occurs inside view public.filter_func_arg_parent_v.
+DROP VIEW filter_func_arg_child_v, filter_func_arg_parent_v;
+DROP TABLE filter_func_child, filter_func_parent;
+DROP FUNCTION filter_stable_identity(int);
+-- FILTER1f: strict proof-filter value allowlist rejects volatile functions
+CREATE FUNCTION filter_volatile_identity(v int) RETURNS int
+LANGUAGE sql VOLATILE AS $$ SELECT v $$;
+CREATE TABLE filter_volatile_parent (id int PRIMARY KEY);
+CREATE TABLE filter_volatile_child
+(
+    parent_id int NOT NULL REFERENCES filter_volatile_parent (id)
+);
+INSERT INTO filter_volatile_parent VALUES (1), (2);
+INSERT INTO filter_volatile_child VALUES (1);
+CREATE VIEW filter_volatile_parent_v AS
+SELECT id FROM filter_volatile_parent WHERE id = filter_volatile_identity(1);
+CREATE VIEW filter_volatile_child_v AS
+SELECT parent_id FROM filter_volatile_child WHERE parent_id = filter_volatile_identity(1);
+SELECT count(*) AS volatile_filter_rows
+FROM filter_volatile_parent_v p
+-- rejected, reason: volatile function filter values are not proof-filter terms
+JOIN filter_volatile_child_v c FOR KEY (parent_id) -> p (id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 4: JOIN filter_volatile_child_v c FOR KEY (parent_id) -> p (id)...
+                                       ^
+DETAIL:  Not every c (parent_id) value can be proven to have a matching p row. Referenced relation p is filtered before this key join. The relevant operation occurs inside view public.filter_volatile_parent_v.
+DROP VIEW filter_volatile_child_v, filter_volatile_parent_v;
+DROP TABLE filter_volatile_child, filter_volatile_parent;
+DROP FUNCTION filter_volatile_identity(int);
+-- FILTER1f2: strict proof-filter allowlist rejects volatile defaults
+CREATE SEQUENCE filter_volatile_default_seq;
+CREATE FUNCTION filter_volatile_default_identity(v int DEFAULT nextval('filter_volatile_default_seq')::int)
+RETURNS int
+LANGUAGE sql STABLE AS $$ SELECT v $$;
+CREATE TABLE filter_volatile_default_parent (id int PRIMARY KEY);
+CREATE TABLE filter_volatile_default_child
+(
+    parent_id int NOT NULL REFERENCES filter_volatile_default_parent (id)
+);
+INSERT INTO filter_volatile_default_parent VALUES (1), (2);
+INSERT INTO filter_volatile_default_child VALUES (1);
+CREATE VIEW filter_volatile_default_parent_v AS
+SELECT id FROM filter_volatile_default_parent
+WHERE id = filter_volatile_default_identity();
+CREATE VIEW filter_volatile_default_child_v AS
+SELECT parent_id FROM filter_volatile_default_child
+WHERE parent_id = filter_volatile_default_identity();
+SELECT count(*) AS volatile_default_filter_rows
+FROM filter_volatile_default_parent_v p
+-- rejected, reason: volatile default function filter values are not proof terms
+JOIN filter_volatile_default_child_v c FOR KEY (parent_id) -> p (id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 4: JOIN filter_volatile_default_child_v c FOR KEY (parent_id) -...
+                                               ^
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+DROP VIEW filter_volatile_default_child_v, filter_volatile_default_parent_v;
+DROP TABLE filter_volatile_default_child, filter_volatile_default_parent;
+DROP FUNCTION filter_volatile_default_identity(int);
+DROP SEQUENCE filter_volatile_default_seq;
+-- FILTER1g: strict proof-filter value allowlist rejects volatile CoerceViaIO
+-- input/output functions before row-coverage propagation.
+SET client_min_messages = warning;
+CREATE TYPE filter_coerce_io_type;
+CREATE FUNCTION filter_coerce_io_type_in(cstring)
+RETURNS filter_coerce_io_type
+LANGUAGE internal STRICT IMMUTABLE AS 'int4in';
+CREATE FUNCTION filter_coerce_io_type_out(filter_coerce_io_type)
+RETURNS cstring
+LANGUAGE internal STRICT IMMUTABLE AS 'int4out';
+CREATE TYPE filter_coerce_io_type (
+    input = filter_coerce_io_type_in,
+    output = filter_coerce_io_type_out,
+    like = int4
+);
+RESET client_min_messages;
+CREATE CAST (filter_coerce_io_type AS integer) WITH INOUT;
+CREATE TABLE filter_coerce_io_parent (id int PRIMARY KEY);
+CREATE TABLE filter_coerce_io_child
+(
+    id int NOT NULL UNIQUE REFERENCES filter_coerce_io_parent (id)
+);
+CREATE TABLE filter_coerce_io_grandchild
+(
+    id int NOT NULL REFERENCES filter_coerce_io_child (id)
+);
+INSERT INTO filter_coerce_io_parent VALUES (1), (2);
+INSERT INTO filter_coerce_io_child VALUES (1), (2);
+INSERT INTO filter_coerce_io_grandchild VALUES (1), (1), (2);
+ALTER FUNCTION filter_coerce_io_type_out(filter_coerce_io_type) VOLATILE;
+SELECT count(*) AS volatile_coerceviaio_filter_rows
+FROM (
+    filter_coerce_io_parent p
+    JOIN filter_coerce_io_child c FOR KEY (id) -> p (id)
+    FILTER (WHERE c.id = ('1'::filter_coerce_io_type)::integer)
+)
+-- rejected, reason: CoerceViaIO output function is volatile
+JOIN filter_coerce_io_grandchild g FOR KEY (id) -> c (id);
+ERROR:  key join from referencing relation g to referenced relation c cannot be proven
+LINE 8: JOIN filter_coerce_io_grandchild g FOR KEY (id) -> c (id);
+                                           ^
+DETAIL:  Not every g (id) value can be proven to have a matching c row. Referenced relation c is filtered before this key join.
+DROP TABLE filter_coerce_io_grandchild, filter_coerce_io_child,
+           filter_coerce_io_parent;
+DROP CAST (filter_coerce_io_type AS integer);
+SET client_min_messages = warning;
+DROP TYPE filter_coerce_io_type CASCADE;
+RESET client_min_messages;
+-- FILTER2: FK left join with FILTER — outer join rows still present
+SELECT * FROM t1
+-- accepted
+  LEFT JOIN t2 FOR KEY (c3) -> t1 (c1)
+  FILTER (WHERE t2.c4 = 10);
+ c1 | c2 | c3 | c4 
+----+----+----+----
+  1 | 10 |  1 | 10
+  2 | 20 |    |   
+  3 | 30 |    |   
+(3 rows)
+
+-- FILTER3: USING join with FILTER clause is not supported
+CREATE TABLE tf1 (id int PRIMARY KEY, val int);
+CREATE TABLE tf2 (id int PRIMARY KEY, val int);
+INSERT INTO tf1 VALUES (1, 10), (2, 20), (3, 30);
+INSERT INTO tf2 VALUES (1, 100), (2, 200);
+-- rejected, reason: join-local FILTER is supported only for FOR KEY joins
+SELECT * FROM tf1 JOIN tf2 USING (id) FILTER (WHERE tf1.val > 10);
+ERROR:  syntax error at or near "FILTER"
+LINE 1: SELECT * FROM tf1 JOIN tf2 USING (id) FILTER (WHERE tf1.val ...
+                                              ^
+-- rejected, reason: same restriction after a USING join alias
+SELECT * FROM tf1 JOIN tf2 USING (id) AS tfj FILTER (WHERE tf2.val > 100);
+ERROR:  syntax error at or near "FILTER"
+LINE 1: SELECT * FROM tf1 JOIN tf2 USING (id) AS tfj FILTER (WHERE t...
+                                                     ^
+-- Ordinary USING joins do not expose key-join facts.
+CREATE TABLE tf_child (id int NOT NULL REFERENCES tf1 (id));
+INSERT INTO tf_child VALUES (1), (2), (3);
+SELECT *
+FROM (SELECT id FROM tf2 JOIN tf1 USING (id)) q
+-- rejected, reason: the ordinary USING join exposes no surface facts
+JOIN tf_child c FOR KEY (id) -> q (id);
+ERROR:  key join from referencing relation c to referenced relation q cannot be proven
+LINE 4: JOIN tf_child c FOR KEY (id) -> q (id);
+                        ^
+DETAIL:  There is no matching foreign key constraint for c (id) referencing q (id).
+DROP TABLE tf_child;
+-- Relabeled projected FK columns are not direct key columns.
+CREATE TABLE relabel_cast_parent (id text PRIMARY KEY);
+CREATE TABLE relabel_cast_child
+(
+    id text NOT NULL REFERENCES relabel_cast_parent (id)
+);
+INSERT INTO relabel_cast_parent VALUES ('a');
+INSERT INTO relabel_cast_child VALUES ('a');
+SELECT *
+FROM relabel_cast_parent p
+JOIN (
+    SELECT id::varchar AS id
+    FROM relabel_cast_child
+) q FOR KEY (id) -> p (id);
+ERROR:  key join from referencing relation q to referenced relation p cannot be proven
+LINE 6: ) q FOR KEY (id) -> p (id);
+            ^
+DETAIL:  There is no matching foreign key constraint for q (id) referencing p (id).
+DROP TABLE relabel_cast_child, relabel_cast_parent;
+-- Relabeled projected FK columns must preserve exact typmod identity.
+CREATE TABLE relabel_typmod_parent (id numeric(10,0) PRIMARY KEY);
+CREATE TABLE relabel_typmod_child
+(
+    id numeric(10,0) NOT NULL REFERENCES relabel_typmod_parent (id)
+);
+INSERT INTO relabel_typmod_parent VALUES (1);
+INSERT INTO relabel_typmod_child VALUES (1);
+SELECT *
+FROM relabel_typmod_parent p
+JOIN (
+    SELECT id::numeric AS id
+    FROM relabel_typmod_child
+) q FOR KEY (id) -> p (id);
+ERROR:  key join from referencing relation q to referenced relation p cannot be proven
+LINE 6: ) q FOR KEY (id) -> p (id);
+            ^
+DETAIL:  There is no matching foreign key constraint for q (id) referencing p (id).
+DROP TABLE relabel_typmod_child, relabel_typmod_parent;
+-- ORDER BY collation relabels do not change projected key identity.
+CREATE COLLATION relabel_key_join_c_copy FROM "C";
+CREATE TABLE relabel_collation_parent (id text COLLATE "C" PRIMARY KEY);
+CREATE TABLE relabel_collation_child
+(
+    id text COLLATE "C" NOT NULL REFERENCES relabel_collation_parent (id)
+);
+INSERT INTO relabel_collation_parent VALUES ('a');
+INSERT INTO relabel_collation_child VALUES ('a');
+SELECT *
+FROM relabel_collation_parent p
+JOIN (
+    SELECT id
+    FROM relabel_collation_child
+    ORDER BY id COLLATE relabel_key_join_c_copy
+) q FOR KEY (id) -> p (id);
+ id | id 
+----+----
+ a  | a
+(1 row)
+
+DROP TABLE relabel_collation_child, relabel_collation_parent;
+DROP COLLATION relabel_key_join_c_copy;
+-- Dropped columns in join inputs are ignored while projecting key facts.
+CREATE TABLE dropped_attr_parent (id int PRIMARY KEY, gone int);
+CREATE TABLE dropped_attr_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES dropped_attr_parent (id),
+    gone int
+);
+ALTER TABLE dropped_attr_parent DROP COLUMN gone;
+ALTER TABLE dropped_attr_child DROP COLUMN gone;
+INSERT INTO dropped_attr_parent VALUES (1);
+INSERT INTO dropped_attr_child VALUES (10, 1);
+SELECT count(*) AS dropped_attr_rows
+FROM dropped_attr_parent p
+JOIN dropped_attr_child c FOR KEY (parent_id) -> p (id);
+ dropped_attr_rows 
+-------------------
+                 1
+(1 row)
+
+DROP TABLE dropped_attr_child, dropped_attr_parent;
+-- FILTER4: FILTER in deparsed view (round-trip test)
+CREATE VIEW v_filter AS
+-- accepted
+  SELECT * FROM t1 JOIN t2 FOR KEY (c3) -> t1 (c1) FILTER (WHERE t2.c4 = 10);
+SELECT pg_get_viewdef('v_filter');
+                           pg_get_viewdef                           
+--------------------------------------------------------------------
+  SELECT t1.c1,                                                    +
+     t1.c2,                                                        +
+     t2.c3,                                                        +
+     t2.c4                                                         +
+    FROM (t1                                                       +
+      JOIN t2 FOR KEY (c3) -> t1 (c1) FILTER (WHERE (t2.c4 = 10)));
+(1 row)
+
+DROP VIEW v_filter;
+-- FILTER5: a filtered key-join result must not export unfiltered rowCoverage
+CREATE TABLE filter_parent (id int PRIMARY KEY);
+CREATE TABLE filter_bridge
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES filter_parent (id),
+    keep boolean NOT NULL
+);
+CREATE TABLE filter_detail
+(
+    id int PRIMARY KEY,
+    bridge_id int NOT NULL REFERENCES filter_bridge (id)
+);
+INSERT INTO filter_parent VALUES (1), (2);
+INSERT INTO filter_bridge VALUES (10, 1, true), (20, 2, false);
+INSERT INTO filter_detail VALUES (100, 10), (200, 20);
+SELECT d.id AS detail_id, q.bridge_id
+FROM filter_detail d
+JOIN (
+    SELECT b.id AS bridge_id
+    FROM filter_parent p
+    JOIN filter_bridge b FOR KEY (parent_id) -> p (id)
+      FILTER (WHERE b.keep)
+) q FOR KEY (bridge_id) <- d (bridge_id);
+ERROR:  key join from referencing relation d to referenced relation q cannot be proven
+LINE 8: ) q FOR KEY (bridge_id) <- d (bridge_id);
+            ^
+DETAIL:  Not every d (bridge_id) value can be proven to have a matching q row. Referenced relation q is filtered before this key join.
+SELECT d.id AS detail_id, q.bridge_id
+FROM filter_detail d
+JOIN (
+    SELECT b.id AS bridge_id
+    FROM filter_parent p
+    JOIN filter_bridge b FOR KEY (parent_id) -> p (id)
+      FILTER (WHERE b.id = 10)
+) q FOR KEY (bridge_id) <- d (bridge_id);
+ERROR:  key join from referencing relation d to referenced relation q cannot be proven
+LINE 8: ) q FOR KEY (bridge_id) <- d (bridge_id);
+            ^
+DETAIL:  Not every d (bridge_id) value can be proven to have a matching q row. Referenced relation q has a filter that is not matched by referencing relation d.
+DROP TABLE filter_detail, filter_bridge, filter_parent;
+-- FILTER5a: referencing-side uniqueness must exactly match the FK key.
+CREATE TABLE filter_unique_len_parent (id int PRIMARY KEY);
+CREATE TABLE filter_unique_len_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES filter_unique_len_parent (id),
+    tag int NOT NULL,
+    UNIQUE (parent_id, tag)
+);
+INSERT INTO filter_unique_len_parent VALUES (1), (2);
+INSERT INTO filter_unique_len_child VALUES (10, 1, 1), (20, 2, 1);
+SELECT count(*) AS unique_len_rows
+FROM filter_unique_len_parent p
+JOIN filter_unique_len_child c FOR KEY (parent_id) -> p (id);
+ unique_len_rows 
+-----------------
+               2
+(1 row)
+
+DROP TABLE filter_unique_len_child, filter_unique_len_parent;
+CREATE TABLE filter_unique_identity_parent (code text COLLATE "C" PRIMARY KEY);
+CREATE TABLE filter_unique_identity_child
+(
+    code text COLLATE "C" NOT NULL
+        REFERENCES filter_unique_identity_parent (code),
+    id int PRIMARY KEY
+);
+CREATE UNIQUE INDEX filter_unique_identity_child_code_idx
+    ON filter_unique_identity_child (code COLLATE "POSIX");
+INSERT INTO filter_unique_identity_parent VALUES ('a'), ('b');
+INSERT INTO filter_unique_identity_child VALUES ('a', 1), ('b', 2);
+SELECT count(*) AS unique_identity_rows
+FROM filter_unique_identity_parent p
+JOIN filter_unique_identity_child c FOR KEY (code) -> p (code);
+ unique_identity_rows 
+----------------------
+                    2
+(1 row)
+
+DROP TABLE filter_unique_identity_child, filter_unique_identity_parent;
+CREATE TABLE filter_sibling_fk_grand (id int PRIMARY KEY);
+CREATE TABLE filter_sibling_fk_parent
+(
+    id int PRIMARY KEY,
+    grand_id int NOT NULL REFERENCES filter_sibling_fk_grand (id)
+);
+CREATE TABLE filter_sibling_fk_child
+(
+    parent_id int NOT NULL UNIQUE REFERENCES filter_sibling_fk_parent (id),
+    payload int PRIMARY KEY
+);
+INSERT INTO filter_sibling_fk_grand VALUES (1), (2);
+INSERT INTO filter_sibling_fk_parent VALUES (1, 1), (2, 2);
+INSERT INTO filter_sibling_fk_child VALUES (1, 10), (2, 20);
+SELECT count(*) AS sibling_fk_rows
+FROM (
+    SELECT c1.parent_id AS c1_parent, c2.parent_id AS c2_parent
+    FROM filter_sibling_fk_parent p
+    LEFT JOIN filter_sibling_fk_child c1
+        FOR KEY (parent_id) -> p (id)
+    LEFT JOIN filter_sibling_fk_child c2
+        FOR KEY (parent_id) -> p (id)
+) q
+LEFT JOIN filter_sibling_fk_parent p2
+    FOR KEY (id) <- q (c1_parent)
+LEFT JOIN filter_sibling_fk_parent p3
+    FOR KEY (id) <- q (c2_parent);
+ sibling_fk_rows 
+-----------------
+               2
+(1 row)
+
+SELECT count(*) AS sibling_filtered_fk_rows
+FROM (
+    SELECT c1.parent_id AS c1_parent, c2.parent_id AS c2_parent
+    FROM filter_sibling_fk_parent p
+    LEFT JOIN filter_sibling_fk_child c1
+        FOR KEY (parent_id) -> p (id)
+    LEFT JOIN filter_sibling_fk_child c2
+        FOR KEY (parent_id) -> p (id)
+        FILTER (WHERE c2.parent_id = 1)
+) q
+LEFT JOIN filter_sibling_fk_parent p2
+    FOR KEY (id) <- q (c1_parent)
+LEFT JOIN filter_sibling_fk_parent p3
+    FOR KEY (id) <- q (c2_parent);
+ sibling_filtered_fk_rows 
+--------------------------
+                        2
+(1 row)
+
+SELECT count(*) AS sibling_unrelated_target_rows
+FROM (
+    SELECT c1.parent_id AS c1_parent, c2.parent_id AS c2_parent
+    FROM filter_sibling_fk_parent p
+    LEFT JOIN filter_sibling_fk_child c1
+        FOR KEY (parent_id) -> p (id)
+    LEFT JOIN filter_sibling_fk_child c2
+        FOR KEY (parent_id) -> p (id)
+        FILTER (WHERE c2.parent_id = 1)
+) q
+LEFT JOIN filter_sibling_fk_parent p2
+    FOR KEY (id) <- q (c1_parent)
+LEFT JOIN filter_sibling_fk_parent p3
+    FOR KEY (id) <- q (c2_parent)
+LEFT JOIN filter_sibling_fk_grand g
+    FOR KEY (id) <- p3 (grand_id);
+ sibling_unrelated_target_rows 
+-------------------------------
+                             2
+(1 row)
+
+DROP TABLE filter_sibling_fk_child, filter_sibling_fk_parent,
+    filter_sibling_fk_grand;
+-- FILTER5b: matched-filter evidence must not propagate to unrelated FK facts
+CREATE TABLE filter_crossprop_a (aid int PRIMARY KEY);
+CREATE TABLE filter_crossprop_c (cid int PRIMARY KEY);
+CREATE TABLE filter_crossprop_b
+(
+    c_id int NOT NULL REFERENCES filter_crossprop_c (cid),
+    a_id int NOT NULL UNIQUE REFERENCES filter_crossprop_a (aid),
+    bid int PRIMARY KEY
+);
+CREATE TABLE filter_crossprop_r
+(
+    r_aid int NOT NULL REFERENCES filter_crossprop_a (aid),
+    rid int PRIMARY KEY
+);
+INSERT INTO filter_crossprop_a VALUES (1);
+INSERT INTO filter_crossprop_c VALUES (1), (2);
+INSERT INTO filter_crossprop_b VALUES (2, 1, 10);
+INSERT INTO filter_crossprop_r VALUES (1, 100);
+CREATE VIEW filter_crossprop_r_filtered AS
+SELECT r_aid, rid FROM filter_crossprop_r WHERE r_aid = 1::int;
+CREATE VIEW filter_crossprop_c_filtered AS
+SELECT cid FROM filter_crossprop_c WHERE cid = 1::int;
+WITH q AS (
+    SELECT ab.aid, ab.c_id, rf.rid
+    FROM (
+        SELECT a.aid, b.c_id, b.bid
+        FROM filter_crossprop_a a
+        LEFT JOIN filter_crossprop_b b FOR KEY (a_id) -> a (aid)
+    ) ab
+    JOIN filter_crossprop_r_filtered rf FOR KEY (r_aid) -> ab (aid)
+)
+SELECT q.aid, q.c_id, cf.cid AS matched_cid
+FROM q
+LEFT JOIN filter_crossprop_c_filtered cf FOR KEY (cid) <- q (c_id);
+ERROR:  key join from referencing relation q to referenced relation cf cannot be proven
+LINE 12: LEFT JOIN filter_crossprop_c_filtered cf FOR KEY (cid) <- q ...
+                                                  ^
+DETAIL:  Not every q (c_id) value can be proven to have a matching cf row. Referenced relation cf has a filter that is not matched by referencing relation q.
+DROP VIEW filter_crossprop_c_filtered, filter_crossprop_r_filtered;
+DROP TABLE filter_crossprop_r, filter_crossprop_b,
+           filter_crossprop_c, filter_crossprop_a;
+-- FILTER5c: boolean proof-filter values must be simple expressions
+CREATE TABLE filter_bool_parent (id boolean PRIMARY KEY);
+CREATE TABLE filter_bool_child
+(
+    parent_id boolean NOT NULL REFERENCES filter_bool_parent (id)
+);
+INSERT INTO filter_bool_parent VALUES (true), (false);
+INSERT INTO filter_bool_child VALUES (true);
+CREATE VIEW filter_bool_parent_or AS
+SELECT id FROM filter_bool_parent WHERE id = (true OR false);
+CREATE VIEW filter_bool_child_or AS
+SELECT parent_id FROM filter_bool_child WHERE parent_id = (true OR false);
+SELECT *
+FROM filter_bool_parent_or p
+-- rejected, reason: BoolExpr filter values are not proof-filter terms
+JOIN filter_bool_child_or c FOR KEY (parent_id) -> p (id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 4: JOIN filter_bool_child_or c FOR KEY (parent_id) -> p (id);
+                                    ^
+DETAIL:  Not every c (parent_id) value can be proven to have a matching p row. Referenced relation p is filtered before this key join. The relevant operation occurs inside view public.filter_bool_parent_or.
+CREATE VIEW filter_bool_parent_test AS
+SELECT id FROM filter_bool_parent WHERE id = (true IS TRUE);
+CREATE VIEW filter_bool_child_test AS
+SELECT parent_id FROM filter_bool_child WHERE parent_id = (true IS TRUE);
+SELECT *
+FROM filter_bool_parent_test p
+-- rejected, reason: BooleanTest filter values are not proof-filter terms
+JOIN filter_bool_child_test c FOR KEY (parent_id) -> p (id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 4: JOIN filter_bool_child_test c FOR KEY (parent_id) -> p (id);
+                                      ^
+DETAIL:  Not every c (parent_id) value can be proven to have a matching p row. Referenced relation p is filtered before this key join. The relevant operation occurs inside view public.filter_bool_parent_test.
+DROP VIEW filter_bool_child_test, filter_bool_parent_test,
+          filter_bool_child_or, filter_bool_parent_or;
+DROP TABLE filter_bool_child, filter_bool_parent;
+-- FILTER5d: pseudo-function filter values are not proof-filter terms
+CREATE TABLE filter_pseudo_parent (id int PRIMARY KEY);
+CREATE TABLE filter_pseudo_child
+(
+    parent_id int NOT NULL REFERENCES filter_pseudo_parent (id)
+);
+INSERT INTO filter_pseudo_parent VALUES (1), (2);
+INSERT INTO filter_pseudo_child VALUES (1);
+CREATE VIEW filter_case_parent_v AS
+SELECT id FROM filter_pseudo_parent
+WHERE id = CASE WHEN true THEN 1 ELSE 2 END;
+CREATE VIEW filter_case_child_v AS
+SELECT parent_id FROM filter_pseudo_child
+WHERE parent_id = CASE WHEN true THEN 1 ELSE 2 END;
+SELECT *
+FROM filter_case_parent_v p
+-- rejected, reason: CaseExpr filter values are not proof-filter terms
+JOIN filter_case_child_v c FOR KEY (parent_id) -> p (id)
+ORDER BY p.id;
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 4: JOIN filter_case_child_v c FOR KEY (parent_id) -> p (id)
+                                   ^
+DETAIL:  Not every c (parent_id) value can be proven to have a matching p row. Referenced relation p is filtered before this key join. The relevant operation occurs inside view public.filter_case_parent_v.
+DROP VIEW filter_case_child_v, filter_case_parent_v;
+CREATE VIEW filter_minmax_parent_v AS
+SELECT id FROM filter_pseudo_parent WHERE id = LEAST(1, 2);
+CREATE VIEW filter_minmax_child_v AS
+SELECT parent_id FROM filter_pseudo_child WHERE parent_id = LEAST(1, 2);
+SELECT count(*) AS minmax_filter_rows
+FROM filter_minmax_parent_v p
+-- rejected, reason: MinMaxExpr filter values are not proof-filter terms
+JOIN filter_minmax_child_v c FOR KEY (parent_id) -> p (id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 4: JOIN filter_minmax_child_v c FOR KEY (parent_id) -> p (id);
+                                     ^
+DETAIL:  Not every c (parent_id) value can be proven to have a matching p row. Referenced relation p is filtered before this key join. The relevant operation occurs inside view public.filter_minmax_parent_v.
+DROP VIEW filter_minmax_child_v, filter_minmax_parent_v;
+CREATE VIEW filter_coalesce_parent_v AS
+SELECT id FROM filter_pseudo_parent WHERE id = COALESCE(1, 2);
+CREATE VIEW filter_coalesce_child_v AS
+SELECT parent_id FROM filter_pseudo_child WHERE parent_id = COALESCE(1, 2);
+SELECT count(*) AS coalesce_filter_rows
+FROM filter_coalesce_parent_v p
+-- rejected, reason: CoalesceExpr filter values are not proof-filter terms
+JOIN filter_coalesce_child_v c FOR KEY (parent_id) -> p (id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 4: JOIN filter_coalesce_child_v c FOR KEY (parent_id) -> p (id)...
+                                       ^
+DETAIL:  Not every c (parent_id) value can be proven to have a matching p row. Referenced relation p is filtered before this key join. The relevant operation occurs inside view public.filter_coalesce_parent_v.
+DROP VIEW filter_coalesce_child_v, filter_coalesce_parent_v;
+CREATE VIEW filter_nullif_parent_v AS
+SELECT id FROM filter_pseudo_parent WHERE id = NULLIF(1, 2);
+CREATE VIEW filter_nullif_child_v AS
+SELECT parent_id FROM filter_pseudo_child WHERE parent_id = NULLIF(1, 2);
+SELECT count(*) AS nullif_filter_rows
+FROM filter_nullif_parent_v p
+-- rejected, reason: NullIfExpr filter values are not proof-filter terms
+JOIN filter_nullif_child_v c FOR KEY (parent_id) -> p (id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 4: JOIN filter_nullif_child_v c FOR KEY (parent_id) -> p (id);
+                                     ^
+DETAIL:  Not every c (parent_id) value can be proven to have a matching p row. Referenced relation p is filtered before this key join. The relevant operation occurs inside view public.filter_nullif_parent_v.
+DROP VIEW filter_nullif_child_v, filter_nullif_parent_v;
+DROP TABLE filter_pseudo_child, filter_pseudo_parent;
+-- FILTER5e: matched-filter propagation skips other same-relation keys
+CREATE TABLE filter_self_rowcov
+(
+    id int PRIMARY KEY,
+    parent_id int UNIQUE NOT NULL REFERENCES filter_self_rowcov (id)
+);
+CREATE TABLE filter_self_rowcov_ref
+(
+    parent_id int NOT NULL REFERENCES filter_self_rowcov (parent_id)
+);
+INSERT INTO filter_self_rowcov VALUES (1, 1), (2, 2);
+INSERT INTO filter_self_rowcov_ref VALUES (1);
+CREATE VIEW filter_self_rowcov_ref_v AS
+SELECT parent_id FROM filter_self_rowcov_ref WHERE parent_id = 1;
+SELECT count(*) AS self_rowcov_rows
+FROM (
+    SELECT c.parent_id
+    FROM filter_self_rowcov p
+    JOIN (
+        SELECT id, parent_id
+        FROM filter_self_rowcov
+        WHERE parent_id = 1
+    ) c FOR KEY (parent_id) -> p (id)
+) q
+JOIN filter_self_rowcov_ref_v r FOR KEY (parent_id) -> q (parent_id);
+ self_rowcov_rows 
+------------------
+                1
+(1 row)
+
+DROP VIEW filter_self_rowcov_ref_v;
+DROP TABLE filter_self_rowcov_ref, filter_self_rowcov;
+-- FILTER5f: matched-filter propagation skips different key arities
+CREATE TABLE filter_len_rowcov
+(
+    id int UNIQUE NOT NULL,
+    a int NOT NULL,
+    b int NOT NULL,
+    parent_a int NOT NULL,
+    parent_b int NOT NULL,
+    UNIQUE (a, b),
+    UNIQUE (parent_a, parent_b),
+    FOREIGN KEY (parent_a, parent_b) REFERENCES filter_len_rowcov (a, b)
+);
+CREATE TABLE filter_len_ref
+(
+    parent_a int NOT NULL,
+    parent_b int NOT NULL,
+    FOREIGN KEY (parent_a, parent_b)
+        REFERENCES filter_len_rowcov (parent_a, parent_b)
+);
+INSERT INTO filter_len_rowcov
+VALUES (100, 1, 1, 1, 1), (200, 2, 2, 2, 2);
+INSERT INTO filter_len_ref VALUES (1, 1);
+CREATE VIEW filter_len_ref_v AS
+SELECT parent_a, parent_b
+FROM filter_len_ref
+WHERE parent_a = 1 AND parent_b = 1;
+SELECT count(*) AS length_mismatch_rows
+FROM (
+    SELECT c.parent_a, c.parent_b
+    FROM filter_len_rowcov p
+    JOIN (
+        SELECT id, a, b, parent_a, parent_b
+        FROM filter_len_rowcov
+        WHERE parent_a = 1 AND parent_b = 1
+    ) c FOR KEY (parent_a, parent_b) -> p (a, b)
+) q
+JOIN filter_len_ref_v r FOR KEY (parent_a, parent_b) -> q (parent_a, parent_b);
+ length_mismatch_rows 
+----------------------
+                    1
+(1 row)
+
+DROP VIEW filter_len_ref_v;
+DROP TABLE filter_len_ref, filter_len_rowcov;
+-- FILTER5g: matched-filter propagation skips different key identities
+CREATE FUNCTION filter_identity_eq(a int, b int) RETURNS boolean
+LANGUAGE sql IMMUTABLE STRICT AS $$ SELECT a = b $$;
+CREATE OPERATOR ===
+(
+    LEFTARG = int,
+    RIGHTARG = int,
+    PROCEDURE = filter_identity_eq,
+    COMMUTATOR = ===,
+    RESTRICT = eqsel,
+    JOIN = eqjoinsel,
+    MERGES
+);
+CREATE OPERATOR CLASS filter_identity_int4_ops
+FOR TYPE int USING btree AS
+    OPERATOR 1 < (int, int),
+    OPERATOR 2 <= (int, int),
+    OPERATOR 3 === (int, int),
+    OPERATOR 4 >= (int, int),
+    OPERATOR 5 > (int, int),
+    FUNCTION 1 btint4cmp(int, int);
+CREATE TABLE filter_identity_grand (id int);
+CREATE UNIQUE INDEX filter_identity_grand_idx
+    ON filter_identity_grand USING btree (id filter_identity_int4_ops);
+CREATE TABLE filter_identity_parent
+(
+    id int PRIMARY KEY REFERENCES filter_identity_grand (id)
+);
+CREATE TABLE filter_identity_child
+(
+    parent_id int NOT NULL REFERENCES filter_identity_parent (id)
+);
+INSERT INTO filter_identity_grand VALUES (1), (2);
+INSERT INTO filter_identity_parent VALUES (1), (2);
+INSERT INTO filter_identity_child VALUES (1);
+CREATE VIEW filter_identity_child_v AS
+SELECT parent_id FROM filter_identity_child WHERE parent_id = 1;
+SELECT count(*) AS identity_mismatch_rows
+FROM (
+    SELECT p.id
+    FROM filter_identity_parent p
+    JOIN filter_identity_child_v c FOR KEY (parent_id) -> p (id)
+) q
+JOIN filter_identity_grand g FOR KEY (id) <- q (id);
+ identity_mismatch_rows 
+------------------------
+                      1
+(1 row)
+
+DROP VIEW filter_identity_child_v;
+DROP TABLE filter_identity_child, filter_identity_parent, filter_identity_grand;
+DROP OPERATOR CLASS filter_identity_int4_ops USING btree;
+DROP OPERATOR FAMILY filter_identity_int4_ops USING btree;
+DROP OPERATOR === (int, int);
+DROP FUNCTION filter_identity_eq(int, int);
+DROP TABLE tf1, tf2;
+-- FILTER6: key join predicate uses the FK equality operator, not search_path "="
+CREATE SCHEMA key_join_shadow;
+SET search_path = key_join_shadow, pg_catalog;
+CREATE FUNCTION int4_always_true(int4, int4) RETURNS boolean
+LANGUAGE sql IMMUTABLE STRICT AS $$ SELECT true $$;
+CREATE OPERATOR = (
+  LEFTARG = int4,
+  RIGHTARG = int4,
+  FUNCTION = int4_always_true,
+  COMMUTATOR = OPERATOR(key_join_shadow.=)
+);
+CREATE TABLE shadow_parent (id int PRIMARY KEY);
+CREATE TABLE shadow_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES shadow_parent (id)
+);
+INSERT INTO shadow_parent VALUES (1), (2);
+INSERT INTO shadow_child VALUES (10, 1);
+SELECT p.id AS parent_id, c.id AS child_id, c.parent_id AS child_parent_id
+FROM shadow_parent p
+-- accepted
+JOIN shadow_child c FOR KEY (parent_id) -> p (id)
+ORDER BY p.id, c.id;
+ parent_id | child_id | child_parent_id 
+-----------+----------+-----------------
+         1 |       10 |               1
+(1 row)
+
+CREATE VIEW shadow_parent_filter AS
+SELECT id FROM shadow_parent WHERE id = 1;
+CREATE VIEW shadow_child_filter AS
+SELECT parent_id FROM shadow_child WHERE parent_id = 1;
+-- rejected, reason: filter "=" is not the exact key equality operator
+SELECT *
+FROM shadow_parent_filter p
+JOIN shadow_child_filter c FOR KEY (parent_id) -> p (id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 3: JOIN shadow_child_filter c FOR KEY (parent_id) -> p (id);
+                                   ^
+DETAIL:  Not every c (parent_id) value can be proven to have a matching p row. Referenced relation p is filtered before this key join. The relevant operation occurs inside view key_join_shadow.shadow_parent_filter.
+DROP VIEW shadow_child_filter, shadow_parent_filter;
+DROP TABLE shadow_child, shadow_parent;
+DROP OPERATOR = (int4, int4);
+DROP FUNCTION int4_always_true(int4, int4);
+DROP SCHEMA key_join_shadow;
+RESET search_path;
+-- FILTER7: matched filters may use compatible cross-type equality
+CREATE TABLE filter_opfamily_parent
+(
+    tenant_id bigint NOT NULL,
+    id bigint NOT NULL,
+    PRIMARY KEY (tenant_id, id)
+);
+CREATE TABLE filter_opfamily_child
+(
+    tenant_id bigint NOT NULL,
+    parent_id bigint NOT NULL,
+    FOREIGN KEY (tenant_id, parent_id)
+        REFERENCES filter_opfamily_parent (tenant_id, id)
+);
+INSERT INTO filter_opfamily_parent VALUES (1, 10), (2, 20);
+INSERT INTO filter_opfamily_child VALUES (1, 10), (2, 20);
+CREATE VIEW filter_opfamily_parent_small AS
+SELECT tenant_id, id
+FROM filter_opfamily_parent
+WHERE tenant_id = 1::smallint;
+CREATE VIEW filter_opfamily_child_small AS
+SELECT tenant_id, parent_id
+FROM filter_opfamily_child
+WHERE tenant_id = 1::smallint;
+CREATE VIEW filter_opfamily_child_big AS
+SELECT tenant_id, parent_id
+FROM filter_opfamily_child
+WHERE tenant_id = 1::bigint;
+CREATE VIEW filter_opfamily_parent_big AS
+SELECT tenant_id, id
+FROM filter_opfamily_parent
+WHERE tenant_id = 1::bigint;
+CREATE VIEW filter_opfamily_parent_int AS
+SELECT tenant_id, id
+FROM filter_opfamily_parent
+WHERE tenant_id = 1::integer;
+CREATE VIEW filter_opfamily_child_int AS
+SELECT tenant_id, parent_id
+FROM filter_opfamily_child
+WHERE tenant_id = 1::integer;
+CREATE VIEW filter_opfamily_parent_right_big AS
+SELECT tenant_id, id
+FROM filter_opfamily_parent
+WHERE 1::bigint = tenant_id;
+-- accepted, reason: both filters use the same compatible bigint = smallint equality
+SELECT *
+FROM filter_opfamily_parent_small p
+JOIN filter_opfamily_child_small c FOR KEY (tenant_id, parent_id) -> p (tenant_id, id)
+ORDER BY p.tenant_id, p.id;
+ tenant_id | id | tenant_id | parent_id 
+-----------+----+-----------+-----------
+         1 | 10 |         1 |        10
+(1 row)
+
+-- accepted, reason: both filters use exact canonical tenant_id = 1::bigint
+SELECT *
+FROM filter_opfamily_parent_big p
+JOIN filter_opfamily_child_big c FOR KEY (tenant_id, parent_id) -> p (tenant_id, id)
+ORDER BY p.tenant_id, p.id;
+ tenant_id | id | tenant_id | parent_id 
+-----------+----+-----------+-----------
+         1 | 10 |         1 |        10
+(1 row)
+
+-- rejected, reason: filter value trees do not match exactly
+SELECT *
+FROM filter_opfamily_parent_small p
+JOIN filter_opfamily_child_big c FOR KEY (tenant_id, parent_id) -> p (tenant_id, id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 3: JOIN filter_opfamily_child_big c FOR KEY (tenant_id, parent_...
+                                         ^
+DETAIL:  Not every c (tenant_id, parent_id) value can be proven to have a matching p row. Referenced relation p has a filter that is not matched by referencing relation c.
+-- accepted, reason: both filters use the same compatible bigint = integer equality
+SELECT *
+FROM filter_opfamily_parent_int p
+JOIN filter_opfamily_child_int c FOR KEY (tenant_id, parent_id) -> p (tenant_id, id);
+ tenant_id | id | tenant_id | parent_id 
+-----------+----+-----------+-----------
+         1 | 10 |         1 |        10
+(1 row)
+
+-- rejected, reason: key-on-right filters are not canonical proof evidence
+SELECT *
+FROM filter_opfamily_parent_right_big p
+JOIN filter_opfamily_child_big c FOR KEY (tenant_id, parent_id) -> p (tenant_id, id)
+ORDER BY p.tenant_id, p.id;
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 3: JOIN filter_opfamily_child_big c FOR KEY (tenant_id, parent_...
+                                         ^
+DETAIL:  Not every c (tenant_id, parent_id) value can be proven to have a matching p row. Referenced relation p is filtered before this key join. The relevant operation occurs inside view public.filter_opfamily_parent_right_big.
+DROP VIEW filter_opfamily_parent_right_big, filter_opfamily_child_int,
+          filter_opfamily_parent_int, filter_opfamily_parent_big,
+          filter_opfamily_child_big,
+          filter_opfamily_child_small, filter_opfamily_parent_small;
+DROP TABLE filter_opfamily_child, filter_opfamily_parent;
+CREATE TABLE filter_opfamily_cross_parent
+(
+    tenant_id bigint NOT NULL,
+    id bigint NOT NULL,
+    PRIMARY KEY (tenant_id, id)
+);
+CREATE TABLE filter_opfamily_cross_child
+(
+    tenant_id integer NOT NULL,
+    parent_id bigint NOT NULL,
+    FOREIGN KEY (tenant_id, parent_id)
+        REFERENCES filter_opfamily_cross_parent (tenant_id, id)
+);
+INSERT INTO filter_opfamily_cross_parent VALUES (1, 10), (2, 20);
+INSERT INTO filter_opfamily_cross_child VALUES (1, 10), (2, 20);
+CREATE VIEW filter_opfamily_cross_parent_small AS
+SELECT tenant_id, id
+FROM filter_opfamily_cross_parent
+WHERE tenant_id = 1::smallint;
+CREATE VIEW filter_opfamily_cross_child_small AS
+SELECT tenant_id, parent_id
+FROM filter_opfamily_cross_child
+WHERE tenant_id = 1::smallint;
+-- rejected, reason: FK and PK key types differ
+SELECT *
+FROM filter_opfamily_cross_parent_small p
+JOIN filter_opfamily_cross_child_small c FOR KEY (tenant_id, parent_id) -> p (tenant_id, id)
+ORDER BY p.tenant_id, p.id;
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 3: JOIN filter_opfamily_cross_child_small c FOR KEY (tenant_id,...
+                                                 ^
+DETAIL:  There is no matching foreign key constraint for c (tenant_id, parent_id) referencing p (tenant_id, id).
+DROP VIEW filter_opfamily_cross_child_small,
+          filter_opfamily_cross_parent_small;
+DROP TABLE filter_opfamily_cross_child, filter_opfamily_cross_parent;
+-- FILTER8: matched filters preserve value typmods structurally
+CREATE TABLE filter_typmod_parent
+(
+    amount numeric(10,2) PRIMARY KEY
+);
+CREATE TABLE filter_typmod_child
+(
+    amount numeric(10,2) NOT NULL REFERENCES filter_typmod_parent (amount)
+);
+INSERT INTO filter_typmod_parent VALUES (1.00), (2.00);
+INSERT INTO filter_typmod_child VALUES (1.00), (2.00);
+CREATE VIEW filter_typmod_parent_short AS
+SELECT amount FROM filter_typmod_parent
+WHERE amount = 1.00::numeric(5,2);
+CREATE VIEW filter_typmod_child_short AS
+SELECT amount FROM filter_typmod_child
+WHERE amount = 1.00::numeric(5,2);
+CREATE VIEW filter_typmod_parent_exact AS
+SELECT amount FROM filter_typmod_parent
+WHERE amount = 1.00::numeric(10,2);
+CREATE VIEW filter_typmod_child_exact AS
+SELECT amount FROM filter_typmod_child
+WHERE amount = 1.00::numeric(10,2);
+-- accepted, reason: both filters use identical value expression typmods
+SELECT *
+FROM filter_typmod_parent_short p
+JOIN filter_typmod_child_short c FOR KEY (amount) -> p (amount);
+ amount | amount 
+--------+--------
+   1.00 |   1.00
+(1 row)
+
+-- accepted, reason: both filters use identical key value typmods
+SELECT *
+FROM filter_typmod_parent_exact p
+JOIN filter_typmod_child_exact c FOR KEY (amount) -> p (amount)
+ORDER BY p.amount;
+ amount | amount 
+--------+--------
+   1.00 |   1.00
+(1 row)
+
+DROP VIEW filter_typmod_child_exact, filter_typmod_parent_exact,
+          filter_typmod_child_short, filter_typmod_parent_short;
+DROP TABLE filter_typmod_child, filter_typmod_parent;
+-- FILTER9: matched filters may use compatible deterministic collations
+CREATE TABLE filter_collation_parent
+(
+    code text COLLATE "C" PRIMARY KEY
+);
+CREATE TABLE filter_collation_child
+(
+    code text COLLATE "C" NOT NULL
+        REFERENCES filter_collation_parent (code)
+);
+INSERT INTO filter_collation_parent VALUES ('a'), ('b');
+INSERT INTO filter_collation_child VALUES ('a'), ('b');
+CREATE VIEW filter_collation_parent_posix AS
+SELECT code FROM filter_collation_parent
+WHERE code = 'a'::text COLLATE "POSIX";
+CREATE VIEW filter_collation_child_posix AS
+SELECT code FROM filter_collation_child
+WHERE code = 'a'::text COLLATE "POSIX";
+CREATE VIEW filter_collation_parent_exact AS
+SELECT code FROM filter_collation_parent
+WHERE code = 'a'::text COLLATE "C";
+CREATE VIEW filter_collation_child_exact AS
+SELECT code FROM filter_collation_child
+WHERE code = 'a'::text COLLATE "C";
+-- accepted, reason: deterministic collations agree on equality
+SELECT *
+FROM filter_collation_parent_posix p
+JOIN filter_collation_child_posix c FOR KEY (code) -> p (code);
+ code | code 
+------+------
+ a    | a
+(1 row)
+
+-- accepted, reason: filter value collations exactly match the key collation
+SELECT *
+FROM filter_collation_parent_exact p
+JOIN filter_collation_child_exact c FOR KEY (code) -> p (code)
+ORDER BY p.code;
+ code | code 
+------+------
+ a    | a
+(1 row)
+
+-- Nondeterministic filter collations are exercised in key_join_icu.
+DROP VIEW filter_collation_child_exact, filter_collation_parent_exact,
+          filter_collation_child_posix, filter_collation_parent_posix;
+DROP TABLE filter_collation_child, filter_collation_parent;
+-- ============================================================
+-- Revalidation via stored Query (no text round-trip)
+-- ============================================================
+-- Set up tables and views for revalidation tests
+-- rv_child uses cid (not id) to avoid column name conflicts in SELECT *
+CREATE TABLE rv_base (id int PRIMARY KEY, v int);
+CREATE TABLE rv_child (cid int PRIMARY KEY, ref int NOT NULL REFERENCES rv_base(id));
+-- rv_parent initially exposes all rows of rv_base (rows_preserved = true)
+CREATE VIEW rv_parent AS SELECT id FROM rv_base;
+-- rv_dep FK-joins rv_child against rv_parent; valid initially
+CREATE VIEW rv_dep AS
+-- accepted
+    SELECT * FROM rv_parent JOIN rv_child FOR KEY (ref) -> rv_parent (id);
+-- Replace rv_parent with a filtered version — rv_dep should become invalid
+-- because the referenced relation no longer preserves all rows.
+-- rejected, reason: replacement would invalidate dependent key-join view
+CREATE OR REPLACE VIEW rv_parent AS SELECT id FROM rv_base WHERE v > 0;
+ERROR:  key join from referencing relation rv_child to referenced relation rv_parent cannot be proven
+DETAIL:  Not every rv_child (ref) value can be proven to have a matching rv_parent row. Referenced relation rv_parent is filtered before this key join.
+-- Replace rv_parent back to unfiltered — rv_dep becomes valid again
+CREATE OR REPLACE VIEW rv_parent AS SELECT id FROM rv_base;
+DROP VIEW rv_dep, rv_parent;
+DROP TABLE rv_child, rv_base;
+-- Stored revalidation failure should format multi-column key lists from the
+-- stored RTE attnums, not just live parse-time column arrays.
+CREATE TABLE rv_multi_parent
+(
+    tenant_id int NOT NULL,
+    id int NOT NULL,
+    v int,
+    PRIMARY KEY (tenant_id, id)
+);
+CREATE TABLE rv_multi_child
+(
+    cid int PRIMARY KEY,
+    tenant_id int NOT NULL,
+    ref int NOT NULL,
+    FOREIGN KEY (tenant_id, ref)
+        REFERENCES rv_multi_parent (tenant_id, id)
+);
+CREATE VIEW rv_multi_parent_v AS
+SELECT tenant_id, id FROM rv_multi_parent;
+CREATE VIEW rv_multi_dep AS
+-- accepted
+    SELECT p.tenant_id, p.id, c.cid
+    FROM rv_multi_parent_v p
+    JOIN rv_multi_child c FOR KEY (tenant_id, ref) -> p (tenant_id, id);
+-- rejected, reason: replacement invalidates a stored composite-key proof
+CREATE OR REPLACE VIEW rv_multi_parent_v AS
+SELECT tenant_id, id FROM rv_multi_parent WHERE v > 0;
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+DETAIL:  Not every c (tenant_id, ref) value can be proven to have a matching p row. Referenced relation p is filtered before this key join.
+DROP VIEW rv_multi_dep, rv_multi_parent_v;
+DROP TABLE rv_multi_child, rv_multi_parent;
+-- Regression: no false positive when view has WHERE on referenced base table.
+-- A naive revalidation passing the stored query to analyze_join_tree would see
+-- jointree->quals != NULL and falsely conclude rows_preserved = false.
+CREATE TABLE rv3_ref (id int PRIMARY KEY);
+CREATE TABLE rv3_child (ref int NOT NULL REFERENCES rv3_ref(id));
+CREATE VIEW rv3_aux AS SELECT 1 AS x;      -- replaced to trigger revalidation
+CREATE VIEW rv3_dep AS
+    SELECT r.id, c.ref
+    FROM rv3_ref r
+-- accepted
+    JOIN rv3_child c FOR KEY (ref) -> r (id)
+    CROSS JOIN rv3_aux
+    WHERE r.id > 0;                         -- WHERE on referenced base table
+-- Replacing rv3_aux revalidates rv3_dep; must succeed (no false positive).
+CREATE OR REPLACE VIEW rv3_aux AS SELECT 2 AS x;
+DROP VIEW rv3_dep, rv3_aux;
+DROP TABLE rv3_child, rv3_ref;
+-- Stored filtered key joins must re-split and re-merge joinFilter quals.
+CREATE TABLE rv_filter_parent (id int PRIMARY KEY);
+CREATE TABLE rv_filter_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES rv_filter_parent (id)
+);
+INSERT INTO rv_filter_parent VALUES (1), (2);
+INSERT INTO rv_filter_child VALUES (10, 1), (20, 2);
+CREATE VIEW rv_filter_child_v AS
+SELECT c.parent_id
+FROM rv_filter_parent p
+JOIN rv_filter_child c FOR KEY (parent_id) -> p (id)
+  FILTER (WHERE c.parent_id = 1);
+SELECT count(*) AS rv_filter_rows
+FROM rv_filter_child_v v
+JOIN rv_filter_parent p FOR KEY (id) <- v (parent_id);
+ rv_filter_rows 
+----------------
+              1
+(1 row)
+
+DROP VIEW rv_filter_child_v;
+DROP TABLE rv_filter_child, rv_filter_parent;
+-- Stored view revalidation must resolve outer CTE references from nested subqueries.
+CREATE TABLE stored_cte_parent (id int PRIMARY KEY);
+CREATE TABLE stored_cte_one_child
+(
+    id int PRIMARY KEY,
+    pid int UNIQUE NOT NULL REFERENCES stored_cte_parent(id)
+);
+CREATE TABLE stored_cte_child
+(
+    id int PRIMARY KEY,
+    pid int NOT NULL REFERENCES stored_cte_parent(id)
+);
+INSERT INTO stored_cte_parent VALUES (1), (2);
+INSERT INTO stored_cte_one_child VALUES (10, 1), (20, 2);
+INSERT INTO stored_cte_child VALUES (100, 1), (101, 1), (200, 2);
+CREATE VIEW stored_cte_parent_v AS
+WITH p AS (SELECT id FROM stored_cte_parent)
+SELECT q.id, oc.id AS one_child_id
+FROM (SELECT id FROM p) q
+LEFT JOIN stored_cte_one_child oc FOR KEY (pid) -> q (id);
+SELECT v.id, c.id AS child_id
+FROM stored_cte_parent_v v
+-- accepted
+JOIN stored_cte_child c FOR KEY (pid) -> v (id)
+ORDER BY v.id, c.id;
+ id | child_id 
+----+----------
+  1 |      100
+  1 |      101
+  2 |      200
+(3 rows)
+
+-- CTE lookup must consume ctelevelsup through the CTE owner's query stack, not
+-- through a nested RTE reference site.
+WITH p AS (SELECT id FROM stored_cte_parent)
+SELECT v.id, c.id AS child_id
+FROM (
+    WITH q AS (SELECT id FROM p),
+         p AS (SELECT id FROM stored_cte_parent WHERE id = 1)
+    SELECT s.id
+    FROM (SELECT id FROM q) s
+) v
+-- accepted, reason: q sees the outer p, not the later inner p
+JOIN stored_cte_child c FOR KEY (pid) -> v (id)
+ORDER BY v.id, c.id;
+ id | child_id 
+----+----------
+  1 |      100
+  1 |      101
+  2 |      200
+(3 rows)
+
+-- Shadowed CTE names must resolve at the RTE's own ctelevelsup.
+CREATE VIEW stored_cte_shadow_v AS
+WITH p AS (SELECT id FROM stored_cte_parent)
+SELECT q.id
+FROM (
+    WITH p AS (SELECT id FROM stored_cte_parent WHERE id = 1)
+    SELECT id FROM p
+) q;
+SELECT v.id, c.id AS child_id
+FROM stored_cte_shadow_v v
+-- rejected, reason: inner shadowing CTE filters referenced rows
+JOIN stored_cte_child c FOR KEY (pid) -> v (id);
+ERROR:  key join from referencing relation c to referenced relation v cannot be proven
+LINE 4: JOIN stored_cte_child c FOR KEY (pid) -> v (id);
+                                ^
+DETAIL:  Not every c (pid) value can be proven to have a matching v row. Referenced relation v has a filter that is not matched by referencing relation c.
+DROP VIEW stored_cte_shadow_v, stored_cte_parent_v;
+DROP TABLE stored_cte_child, stored_cte_one_child, stored_cte_parent;
+-- Revalidation must not re-store dependent _RETURN rules.  A replacement that
+-- would need a different FK proof source is rejected until the dependent view
+-- is explicitly recreated.
+CREATE TABLE rv_rewrite_parent_a (id int PRIMARY KEY);
+CREATE TABLE rv_rewrite_parent_b (id int PRIMARY KEY);
+CREATE TABLE rv_rewrite_child
+(
+    cid int PRIMARY KEY,
+    ref int NOT NULL,
+    FOREIGN KEY (ref) REFERENCES rv_rewrite_parent_a (id),
+    FOREIGN KEY (ref) REFERENCES rv_rewrite_parent_b (id)
+);
+INSERT INTO rv_rewrite_parent_a VALUES (1);
+INSERT INTO rv_rewrite_parent_b VALUES (1);
+INSERT INTO rv_rewrite_child VALUES (10, 1);
+CREATE VIEW rv_rewrite_parent_v AS SELECT id FROM rv_rewrite_parent_a;
+CREATE VIEW rv_rewrite_child_v AS SELECT cid, ref FROM rv_rewrite_child;
+CREATE VIEW rv_rewrite_dep AS
+SELECT p.id AS parent_id, c.cid AS child_id
+FROM rv_rewrite_parent_v p
+-- accepted
+JOIN rv_rewrite_child_v c FOR KEY (ref) -> p (id);
+-- rejected, reason: replay would require a new proof dependency
+CREATE OR REPLACE VIEW rv_rewrite_parent_v AS SELECT id FROM rv_rewrite_parent_b;
+ERROR:  stored key join proof would require new dependencies
+DROP VIEW rv_rewrite_dep;
+CREATE OR REPLACE VIEW rv_rewrite_parent_v AS SELECT id FROM rv_rewrite_parent_b;
+CREATE VIEW rv_rewrite_dep AS
+SELECT p.id AS parent_id, c.cid AS child_id
+FROM rv_rewrite_parent_v p
+-- accepted
+JOIN rv_rewrite_child_v c FOR KEY (ref) -> p (id);
+SELECT * FROM rv_rewrite_dep ORDER BY parent_id, child_id;
+ parent_id | child_id 
+-----------+----------
+         1 |       10
+(1 row)
+
+DROP VIEW rv_rewrite_dep, rv_rewrite_child_v, rv_rewrite_parent_v;
+DROP TABLE rv_rewrite_child, rv_rewrite_parent_b, rv_rewrite_parent_a;
+-- Revalidation rejects proof switches that would rebuild executable key
+-- equality with a different operator.  Explicit recreation stores the new
+-- proof normally.
+CREATE FUNCTION key_join_bucket_cmp(a int, b int) RETURNS int
+LANGUAGE sql IMMUTABLE STRICT AS
+$$ SELECT CASE WHEN a / 10 < b / 10 THEN -1
+               WHEN a / 10 > b / 10 THEN 1
+               ELSE 0 END $$;
+CREATE FUNCTION key_join_bucket_eq(a int, b int) RETURNS boolean
+LANGUAGE sql IMMUTABLE STRICT AS $$ SELECT a / 10 = b / 10 $$;
+CREATE OPERATOR =# (
+    LEFTARG = int,
+    RIGHTARG = int,
+    FUNCTION = key_join_bucket_eq,
+    COMMUTATOR = =#,
+    RESTRICT = eqsel,
+    JOIN = eqjoinsel,
+    MERGES
+);
+CREATE OPERATOR CLASS key_join_bucket_int4_ops
+FOR TYPE int USING btree AS
+    OPERATOR 3 =# (int, int),
+    FUNCTION 1 key_join_bucket_cmp(int, int);
+CREATE TABLE rv_sound_parent_a (id int PRIMARY KEY);
+CREATE TABLE rv_sound_parent_b (id int);
+CREATE UNIQUE INDEX rv_sound_parent_b_idx
+    ON rv_sound_parent_b USING btree (id key_join_bucket_int4_ops);
+CREATE TABLE rv_sound_child
+(
+    cid int PRIMARY KEY,
+    pid int NOT NULL,
+    FOREIGN KEY (pid) REFERENCES rv_sound_parent_a (id),
+    FOREIGN KEY (pid) REFERENCES rv_sound_parent_b (id)
+);
+INSERT INTO rv_sound_parent_a VALUES (11);
+INSERT INTO rv_sound_parent_b VALUES (10);
+INSERT INTO rv_sound_child VALUES (1, 11);
+CREATE VIEW rv_sound_parent_v AS SELECT id FROM rv_sound_parent_a;
+CREATE VIEW rv_sound_child_v AS SELECT cid, pid FROM rv_sound_child;
+CREATE VIEW rv_sound_dep AS
+SELECT p.id AS parent_id, c.cid AS child_id, c.pid
+FROM rv_sound_parent_v p
+-- accepted
+JOIN rv_sound_child_v c FOR KEY (pid) -> p (id);
+-- rejected, reason: replay would change executable key equality
+CREATE OR REPLACE VIEW rv_sound_parent_v AS SELECT id FROM rv_sound_parent_b;
+ERROR:  stored key join proof would require new dependencies
+SELECT * FROM rv_sound_dep ORDER BY parent_id, child_id;
+ parent_id | child_id | pid 
+-----------+----------+-----
+        11 |        1 |  11
+(1 row)
+
+DROP VIEW rv_sound_dep;
+CREATE OR REPLACE VIEW rv_sound_parent_v AS SELECT id FROM rv_sound_parent_b;
+CREATE VIEW rv_sound_dep AS
+SELECT p.id AS parent_id, c.cid AS child_id, c.pid
+FROM rv_sound_parent_v p
+-- accepted
+JOIN rv_sound_child_v c FOR KEY (pid) -> p (id);
+SELECT * FROM rv_sound_dep ORDER BY parent_id, child_id;
+ parent_id | child_id | pid 
+-----------+----------+-----
+        10 |        1 |  11
+(1 row)
+
+DROP VIEW rv_sound_dep;
+CREATE OR REPLACE VIEW rv_sound_parent_v AS SELECT id FROM rv_sound_parent_a;
+-- Non-_RETURN rule actions are only validated; reject a proof switch that
+-- would require persisting a rebuilt action tree.
+CREATE TABLE rv_sound_rule_event (id int);
+CREATE RULE rv_sound_rule_action AS ON DELETE TO rv_sound_rule_event
+DO ALSO SELECT p.id AS parent_id, c.cid AS child_id, c.pid
+FROM rv_sound_parent_v p
+-- accepted
+JOIN rv_sound_child_v c FOR KEY (pid) -> p (id);
+-- rejected, reason: non-_RETURN rule action would need rewritten key quals
+CREATE OR REPLACE VIEW rv_sound_parent_v AS SELECT id FROM rv_sound_parent_b;
+ERROR:  stored key join proof would require new dependencies
+DROP RULE rv_sound_rule_action ON rv_sound_rule_event;
+DROP VIEW rv_sound_child_v, rv_sound_parent_v;
+DROP TABLE rv_sound_rule_event, rv_sound_child, rv_sound_parent_b, rv_sound_parent_a;
+DROP OPERATOR CLASS key_join_bucket_int4_ops USING btree;
+DROP OPERATOR FAMILY key_join_bucket_int4_ops USING btree;
+DROP OPERATOR =# (int, int);
+DROP FUNCTION key_join_bucket_cmp(int, int);
+DROP FUNCTION key_join_bucket_eq(int, int);
+-- Valid replacement changes facts that are recomputed for dependent views.
+CREATE TABLE rv_refresh_parent
+(
+    tenant_id int NOT NULL,
+    id int NOT NULL,
+    PRIMARY KEY (tenant_id, id)
+);
+CREATE TABLE rv_refresh_child
+(
+    tenant_id int NOT NULL,
+    parent_id int NOT NULL,
+    UNIQUE (tenant_id, parent_id),
+    FOREIGN KEY (tenant_id, parent_id)
+        REFERENCES rv_refresh_parent (tenant_id, id)
+);
+CREATE TABLE rv_refresh_reader
+(
+    tenant_id int NOT NULL,
+    parent_id int NOT NULL,
+    FOREIGN KEY (tenant_id, parent_id)
+        REFERENCES rv_refresh_parent (tenant_id, id)
+);
+CREATE VIEW rv_refresh_parent_v AS
+SELECT tenant_id, id FROM rv_refresh_parent;
+CREATE VIEW rv_refresh_child_v AS
+SELECT tenant_id, parent_id FROM rv_refresh_child WHERE tenant_id = 1;
+CREATE VIEW rv_refresh_dep AS
+SELECT p.tenant_id, p.id
+FROM rv_refresh_parent_v p
+-- accepted
+LEFT JOIN rv_refresh_child_v c FOR KEY (tenant_id, parent_id) -> p (tenant_id, id);
+-- This replacement remains valid for rv_refresh_dep, but recomputing its
+-- output facts now yields conditional row coverage.
+CREATE OR REPLACE VIEW rv_refresh_parent_v AS
+SELECT tenant_id, id FROM rv_refresh_parent WHERE tenant_id = 1;
+-- rejected, reason: recomputed dependent facts now require the tenant filter
+SELECT *
+FROM rv_refresh_reader r
+JOIN rv_refresh_dep d FOR KEY (tenant_id, id) <- r (tenant_id, parent_id);
+ERROR:  key join from referencing relation r to referenced relation d cannot be proven
+LINE 3: JOIN rv_refresh_dep d FOR KEY (tenant_id, id) <- r (tenant_i...
+                              ^
+DETAIL:  Not every r (tenant_id, parent_id) value can be proven to have a matching d row. Referenced relation d has a filter that is not matched by referencing relation r.
+-- accepted, reason: referencing-side tenant filter matches the recomputed fact
+SELECT *
+FROM (SELECT * FROM rv_refresh_reader WHERE tenant_id = 1) r
+JOIN rv_refresh_dep d FOR KEY (tenant_id, id) <- r (tenant_id, parent_id);
+ tenant_id | parent_id | tenant_id | id 
+-----------+-----------+-----------+----
+(0 rows)
+
+DROP VIEW rv_refresh_dep, rv_refresh_child_v, rv_refresh_parent_v;
+DROP TABLE rv_refresh_reader, rv_refresh_child, rv_refresh_parent;
+-- Stored key joins activate FK provenance from producer views.  The producer
+-- view alone does not block dropping the FK.
+CREATE TABLE ondemand_fk_parent
+(
+    id int PRIMARY KEY
+);
+CREATE TABLE ondemand_fk_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL,
+    CONSTRAINT ondemand_fk_child_parent_id_fkey
+        FOREIGN KEY (parent_id) REFERENCES ondemand_fk_parent (id)
+);
+CREATE VIEW ondemand_fk_child_v AS
+SELECT id, parent_id FROM ondemand_fk_child;
+ALTER TABLE ondemand_fk_child DROP CONSTRAINT ondemand_fk_child_parent_id_fkey;
+ALTER TABLE ondemand_fk_child ADD CONSTRAINT ondemand_fk_child_parent_id_fkey
+    FOREIGN KEY (parent_id) REFERENCES ondemand_fk_parent (id);
+CREATE VIEW ondemand_fk_consumer AS
+SELECT p.id AS parent_id, c.id AS child_id, c.parent_id AS child_parent_id
+FROM ondemand_fk_parent p
+-- accepted
+JOIN ondemand_fk_child_v c FOR KEY (parent_id) -> p (id);
+ALTER TABLE ondemand_fk_child DROP CONSTRAINT ondemand_fk_child_parent_id_fkey; -- rejected, reason: consumer view depends on the FK proof
+ERROR:  cannot drop constraint ondemand_fk_child_parent_id_fkey on table ondemand_fk_child because other objects depend on it
+DETAIL:  view ondemand_fk_consumer depends on constraint ondemand_fk_child_parent_id_fkey on table ondemand_fk_child
+HINT:  Use DROP ... CASCADE to drop the dependent objects too.
+DROP VIEW ondemand_fk_consumer;
+ALTER TABLE ondemand_fk_child DROP CONSTRAINT ondemand_fk_child_parent_id_fkey;
+SELECT *
+FROM ondemand_fk_parent p
+-- rejected, reason: view facts are recomputed after the FK constraint was dropped
+JOIN ondemand_fk_child_v c FOR KEY (parent_id) -> p (id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 4: JOIN ondemand_fk_child_v c FOR KEY (parent_id) -> p (id);
+                                   ^
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+DROP VIEW ondemand_fk_child_v;
+DROP TABLE ondemand_fk_child, ondemand_fk_parent;
+-- Revalidation may accept dependency shrinkage without rewriting a stored
+-- dependent view.
+CREATE TABLE rv_shrink_parent
+(
+    id int PRIMARY KEY
+);
+CREATE TABLE rv_shrink_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES rv_shrink_parent (id)
+);
+CREATE TABLE rv_shrink_one_child
+(
+    id int PRIMARY KEY,
+    parent_id int UNIQUE NOT NULL REFERENCES rv_shrink_parent (id)
+);
+INSERT INTO rv_shrink_parent VALUES (1), (2);
+INSERT INTO rv_shrink_child VALUES (10, 1), (20, 2);
+INSERT INTO rv_shrink_one_child VALUES (100, 1);
+CREATE VIEW rv_shrink_parent_v AS
+SELECT p.id
+FROM rv_shrink_parent p
+LEFT JOIN rv_shrink_one_child oc FOR KEY (parent_id) -> p (id);
+CREATE VIEW rv_shrink_dep AS
+SELECT p.id AS parent_id
+FROM rv_shrink_parent_v p
+JOIN rv_shrink_child c FOR KEY (parent_id) -> p (id);
+CREATE OR REPLACE VIEW rv_shrink_parent_v AS
+SELECT id FROM rv_shrink_parent;
+SELECT count(*) AS rv_shrink_rows FROM rv_shrink_dep;
+ rv_shrink_rows 
+----------------
+              2
+(1 row)
+
+DROP VIEW rv_shrink_dep, rv_shrink_parent_v;
+DROP TABLE rv_shrink_one_child, rv_shrink_child, rv_shrink_parent;
+-- Revalidation must reject a replay that would require a new not-null
+-- proof dependency.
+CREATE TABLE rv_new_nn_parent
+(
+    id int PRIMARY KEY
+);
+CREATE TABLE rv_new_nn_child_a
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES rv_new_nn_parent (id)
+);
+CREATE TABLE rv_new_nn_child_b
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES rv_new_nn_parent (id)
+);
+INSERT INTO rv_new_nn_parent VALUES (1), (2);
+INSERT INTO rv_new_nn_child_a VALUES (10, 1);
+INSERT INTO rv_new_nn_child_b VALUES (20, 2);
+CREATE VIEW rv_new_nn_child_v AS
+SELECT id, parent_id FROM rv_new_nn_child_a;
+CREATE VIEW rv_new_nn_dep AS
+SELECT c.id AS child_id, c.parent_id
+FROM rv_new_nn_parent p
+JOIN rv_new_nn_child_v c FOR KEY (parent_id) -> p (id);
+-- rejected, reason: replay would require child_b's NOT NULL proof
+CREATE OR REPLACE VIEW rv_new_nn_child_v AS
+SELECT id, parent_id FROM rv_new_nn_child_b;
+ERROR:  stored key join proof would require new dependencies
+SELECT * FROM rv_new_nn_dep ORDER BY child_id;
+ child_id | parent_id 
+----------+-----------
+       10 |         1
+(1 row)
+
+DROP VIEW rv_new_nn_dep, rv_new_nn_child_v;
+DROP TABLE rv_new_nn_child_b, rv_new_nn_child_a, rv_new_nn_parent;
+-- Stored key joins must be revalidated when FK proof flags change.
+CREATE TABLE alter_fk_parent
+(
+    id int PRIMARY KEY
+);
+CREATE TABLE alter_fk_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL,
+    CONSTRAINT alter_fk_child_parent_id_fkey
+        FOREIGN KEY (parent_id) REFERENCES alter_fk_parent (id)
+);
+INSERT INTO alter_fk_parent VALUES (1);
+INSERT INTO alter_fk_child VALUES (10, 1);
+CREATE VIEW alter_fk_consumer AS
+SELECT p.id AS parent_id, c.id AS child_id
+FROM alter_fk_parent p
+-- accepted
+JOIN alter_fk_child c FOR KEY (parent_id) -> p (id);
+-- rejected, reason: stored view depends on the FK proof
+ALTER TABLE alter_fk_child
+    ALTER CONSTRAINT alter_fk_child_parent_id_fkey NOT ENFORCED;
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+SELECT * FROM alter_fk_consumer ORDER BY child_id;
+ parent_id | child_id 
+-----------+----------
+         1 |       10
+(1 row)
+
+-- rejected, reason: stored view depends on the non-deferrable FK proof
+ALTER TABLE alter_fk_child
+    ALTER CONSTRAINT alter_fk_child_parent_id_fkey
+    DEFERRABLE INITIALLY IMMEDIATE;
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+DROP VIEW alter_fk_consumer;
+CREATE VIEW alter_fk_scalar_consumer AS
+SELECT (
+    SELECT count(*)
+    FROM alter_fk_parent p
+    -- accepted
+    JOIN alter_fk_child c FOR KEY (parent_id) -> p (id)
+) AS key_join_rows;
+-- rejected, reason: dependency scanning sees the scalar subquery proof
+ALTER TABLE alter_fk_child DROP CONSTRAINT alter_fk_child_parent_id_fkey;
+ERROR:  cannot drop constraint alter_fk_child_parent_id_fkey on table alter_fk_child because other objects depend on it
+DETAIL:  view alter_fk_scalar_consumer depends on constraint alter_fk_child_parent_id_fkey on table alter_fk_child
+HINT:  Use DROP ... CASCADE to drop the dependent objects too.
+-- rejected, reason: stored scalar subquery proof depends on the FK proof
+ALTER TABLE alter_fk_child
+    ALTER CONSTRAINT alter_fk_child_parent_id_fkey NOT ENFORCED;
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+DROP VIEW alter_fk_scalar_consumer;
+ALTER TABLE alter_fk_child
+    ALTER CONSTRAINT alter_fk_child_parent_id_fkey NOT ENFORCED;
+SELECT p.id AS parent_id, c.id AS child_id
+FROM alter_fk_parent p
+-- rejected, reason: FK is now not enforced
+JOIN alter_fk_child c FOR KEY (parent_id) -> p (id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 4: JOIN alter_fk_child c FOR KEY (parent_id) -> p (id);
+                              ^
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+DROP TABLE alter_fk_child, alter_fk_parent;
+-- Key join follows PostgreSQL's catalog-level FK validity, not current
+-- RI trigger firing state.
+CREATE TABLE trigger_bypass_parent (id int PRIMARY KEY);
+CREATE TABLE trigger_bypass_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL,
+    CONSTRAINT trigger_bypass_child_parent_id_fkey
+        FOREIGN KEY (parent_id) REFERENCES trigger_bypass_parent (id)
+);
+INSERT INTO trigger_bypass_parent VALUES (1);
+INSERT INTO trigger_bypass_child VALUES (10, 1);
+ALTER TABLE trigger_bypass_child DISABLE TRIGGER ALL;
+ALTER TABLE trigger_bypass_parent DISABLE TRIGGER ALL;
+SELECT count(*) AS key_join_rows
+FROM trigger_bypass_parent p
+JOIN trigger_bypass_child c FOR KEY (parent_id) -> p (id);
+ key_join_rows 
+---------------
+             1
+(1 row)
+
+ALTER TABLE trigger_bypass_child ENABLE TRIGGER ALL;
+ALTER TABLE trigger_bypass_parent ENABLE TRIGGER ALL;
+DROP TABLE trigger_bypass_child, trigger_bypass_parent;
+-- session_replication_role also does not affect key join proof validity.
+CREATE TABLE trigger_bypass_repl_parent (id int PRIMARY KEY);
+CREATE TABLE trigger_bypass_repl_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL,
+    CONSTRAINT trigger_bypass_repl_child_parent_id_fkey
+        FOREIGN KEY (parent_id) REFERENCES trigger_bypass_repl_parent (id)
+);
+INSERT INTO trigger_bypass_repl_parent VALUES (1);
+INSERT INTO trigger_bypass_repl_child VALUES (10, 1);
+SET session_replication_role = replica;
+SELECT count(*) AS key_join_rows
+FROM trigger_bypass_repl_parent p
+JOIN trigger_bypass_repl_child c FOR KEY (parent_id) -> p (id);
+ key_join_rows 
+---------------
+             1
+(1 row)
+
+RESET session_replication_role;
+DROP TABLE trigger_bypass_repl_child, trigger_bypass_repl_parent;
+-- Consumed query-shape uniqueness is revalidated on dependent view
+-- replacement.
+CREATE TABLE rv_unique_parent
+(
+    id int PRIMARY KEY
+);
+CREATE TABLE rv_unique_child
+(
+    parent_id int NOT NULL REFERENCES rv_unique_parent (id)
+);
+CREATE VIEW rv_unique_parent_v AS
+SELECT id FROM rv_unique_parent GROUP BY id;
+CREATE VIEW rv_unique_dep AS
+SELECT *
+FROM rv_unique_parent_v p
+-- accepted
+JOIN rv_unique_child c FOR KEY (parent_id) -> p (id);
+-- rejected, reason: replacement removes consumed query-shape uniqueness and row coverage
+CREATE OR REPLACE VIEW rv_unique_parent_v AS
+SELECT p.id FROM rv_unique_parent p CROSS JOIN (VALUES (1), (2)) AS g(n);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+DROP VIEW rv_unique_dep, rv_unique_parent_v;
+DROP TABLE rv_unique_child, rv_unique_parent;
+-- Bare unique indexes are uniqueness sources for surface facts, but producer
+-- views do not store those facts as DDL-blocking dependencies.
+CREATE TABLE bare_unique_parent
+(
+    id int NOT NULL
+);
+CREATE UNIQUE INDEX bare_unique_parent_id_idx ON bare_unique_parent (id);
+CREATE VIEW bare_unique_parent_v AS
+SELECT id FROM bare_unique_parent;
+DROP INDEX bare_unique_parent_id_idx;
+DROP VIEW bare_unique_parent_v;
+DROP TABLE bare_unique_parent;
+-- Adding an inheritance child to a parent that backs a stored key-join
+-- proof must revalidate dependent views and abort if the proof breaks.
+CREATE TABLE inh_kj_parent (id int PRIMARY KEY);
+CREATE TABLE inh_kj_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES inh_kj_parent (id)
+);
+INSERT INTO inh_kj_parent VALUES (1);
+INSERT INTO inh_kj_child VALUES (10, 1);
+CREATE VIEW inh_kj_v AS
+SELECT p.id AS parent_id, c.id AS child_id
+FROM inh_kj_parent p
+-- accepted
+JOIN inh_kj_child c FOR KEY (parent_id) -> p (id);
+-- rejected, reason: stored view depends on parent having no subclasses
+CREATE TABLE inh_kj_subclass () INHERITS (inh_kj_parent);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+CREATE TABLE inh_kj_outsider (id int NOT NULL);
+-- rejected, reason: same dependency, ALTER TABLE INHERIT path
+ALTER TABLE inh_kj_outsider INHERIT inh_kj_parent;
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+-- After dropping the dependent view both DDL paths succeed.
+DROP VIEW inh_kj_v;
+CREATE TABLE inh_kj_subclass () INHERITS (inh_kj_parent);
+ALTER TABLE inh_kj_outsider INHERIT inh_kj_parent;
+DROP TABLE inh_kj_outsider, inh_kj_subclass, inh_kj_child, inh_kj_parent;
+-- Partitioned-table parent: ATTACH PARTITION must NOT trigger a false
+-- positive, because the parent's unique constraint is enforced across
+-- partitions.
+CREATE TABLE inh_kj_pparent (id int PRIMARY KEY) PARTITION BY RANGE (id);
+CREATE TABLE inh_kj_ppart0 PARTITION OF inh_kj_pparent
+    FOR VALUES FROM (0) TO (100);
+CREATE TABLE inh_kj_pchild
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES inh_kj_pparent (id)
+);
+INSERT INTO inh_kj_pparent VALUES (1);
+INSERT INTO inh_kj_pchild VALUES (10, 1);
+CREATE VIEW inh_kj_pv AS
+SELECT p.id AS parent_id, c.id AS child_id
+FROM inh_kj_pparent p
+-- accepted
+JOIN inh_kj_pchild c FOR KEY (parent_id) -> p (id);
+-- accepted: attaching another partition leaves the proof valid because
+-- partitioned-table parents enforce uniqueness across partitions.
+CREATE TABLE inh_kj_ppart1 PARTITION OF inh_kj_pparent
+    FOR VALUES FROM (100) TO (200);
+SELECT * FROM inh_kj_pv ORDER BY child_id;
+ parent_id | child_id 
+-----------+----------
+         1 |       10
+(1 row)
+
+DROP VIEW inh_kj_pv;
+DROP TABLE inh_kj_pchild, inh_kj_pparent;
+-- ALTER TABLE ENABLE ROW LEVEL SECURITY must revalidate stored key-join
+-- proofs.  RLS-protected relations expose no key-join facts, so a stored
+-- proof against a relation that gains RLS becomes unprovable.
+CREATE TABLE rls_kj_parent (id int PRIMARY KEY);
+CREATE TABLE rls_kj_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES rls_kj_parent (id)
+);
+INSERT INTO rls_kj_parent VALUES (1);
+INSERT INTO rls_kj_child VALUES (10, 1);
+CREATE VIEW rls_kj_v AS
+SELECT p.id AS parent_id, c.id AS child_id
+FROM rls_kj_parent p
+-- accepted
+JOIN rls_kj_child c FOR KEY (parent_id) -> p (id);
+-- rejected, reason: stored view depends on RLS-free referenced surface
+ALTER TABLE rls_kj_parent ENABLE ROW LEVEL SECURITY;
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+-- rejected, reason: same dependency, referencing side too
+ALTER TABLE rls_kj_child ENABLE ROW LEVEL SECURITY;
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+-- After dropping the view, ENABLE ROW LEVEL SECURITY succeeds.
+DROP VIEW rls_kj_v;
+ALTER TABLE rls_kj_parent ENABLE ROW LEVEL SECURITY;
+-- Repeating the same setting does not need key-join revalidation.
+ALTER TABLE rls_kj_parent ENABLE ROW LEVEL SECURITY;
+ALTER TABLE rls_kj_parent DISABLE ROW LEVEL SECURITY;
+DROP TABLE rls_kj_child, rls_kj_parent;
+-- Materialized views must follow the same proof revalidation path as
+-- regular views.  Their _RETURN rule has the same shape, but the revalidation
+-- helper used to skip them because of a RELKIND_VIEW filter.
+CREATE TABLE mv_kj_parent (id int PRIMARY KEY);
+CREATE TABLE mv_kj_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES mv_kj_parent (id)
+);
+INSERT INTO mv_kj_parent VALUES (1);
+INSERT INTO mv_kj_child VALUES (10, 1);
+CREATE MATERIALIZED VIEW mv_kj AS
+SELECT p.id AS parent_id, c.id AS child_id
+FROM mv_kj_parent p
+-- accepted
+JOIN mv_kj_child c FOR KEY (parent_id) -> p (id);
+-- rejected, reason: stored matview proof depends on parent having no subclasses
+CREATE TABLE mv_kj_subclass () INHERITS (mv_kj_parent);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+-- rejected, reason: stored matview proof depends on RLS-free referenced surface
+ALTER TABLE mv_kj_parent ENABLE ROW LEVEL SECURITY;
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+DROP MATERIALIZED VIEW mv_kj;
+DROP TABLE mv_kj_child, mv_kj_parent;
+-- Rules on plain tables can also contain FOR KEY joins in their action.
+-- The stored rule action carries proof annotations exactly like a view's
+-- _RETURN rule, so it must participate in the same revalidation.
+CREATE TABLE rule_kj_parent (id int PRIMARY KEY);
+CREATE TABLE rule_kj_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES rule_kj_parent (id)
+);
+CREATE TABLE rule_kj_log (id int);
+INSERT INTO rule_kj_parent VALUES (1);
+INSERT INTO rule_kj_child VALUES (10, 1);
+CREATE RULE rule_kj_action AS ON DELETE TO rule_kj_log
+DO ALSO SELECT count(*) FROM rule_kj_parent p
+-- accepted
+JOIN rule_kj_child c FOR KEY (parent_id) -> p (id);
+-- rejected, reason: stored rule action depends on parent having no subclasses
+CREATE TABLE rule_kj_subclass () INHERITS (rule_kj_parent);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+-- rejected, reason: stored rule action depends on RLS-free referenced surface
+ALTER TABLE rule_kj_parent ENABLE ROW LEVEL SECURITY;
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+-- rejected, reason: stored rule action depends on the FK proof
+ALTER TABLE rule_kj_child
+    ALTER CONSTRAINT rule_kj_child_parent_id_fkey NOT ENFORCED;
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+-- After dropping the rule, the same DDL succeeds.
+DROP RULE rule_kj_action ON rule_kj_log;
+CREATE TABLE rule_kj_subclass () INHERITS (rule_kj_parent);
+DROP TABLE rule_kj_subclass, rule_kj_log, rule_kj_child, rule_kj_parent;
+-- Rule qualifications can also contain stored key-join proofs in sublinks.
+CREATE TABLE rule_qual_kj_parent (id int PRIMARY KEY);
+CREATE TABLE rule_qual_kj_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES rule_qual_kj_parent (id)
+);
+CREATE TABLE rule_qual_kj_log (id int);
+INSERT INTO rule_qual_kj_parent VALUES (1);
+INSERT INTO rule_qual_kj_child VALUES (10, 1);
+CREATE RULE rule_qual_kj AS ON INSERT TO rule_qual_kj_log
+WHERE EXISTS (
+    SELECT 1
+    FROM rule_qual_kj_parent p
+    -- accepted
+    JOIN rule_qual_kj_child c FOR KEY (parent_id) -> p (id)
+)
+DO ALSO SELECT 1;
+-- rejected, reason: stored rule qualification depends on the FK proof
+ALTER TABLE rule_qual_kj_child
+    ALTER CONSTRAINT rule_qual_kj_child_parent_id_fkey NOT ENFORCED;
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+-- After dropping the rule, the same DDL succeeds.
+DROP RULE rule_qual_kj ON rule_qual_kj_log;
+ALTER TABLE rule_qual_kj_child
+    ALTER CONSTRAINT rule_qual_kj_child_parent_id_fkey NOT ENFORCED;
+SELECT *
+FROM rule_qual_kj_parent p
+-- rejected, reason: FK is now not enforced
+JOIN rule_qual_kj_child c FOR KEY (parent_id) -> p (id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 4: JOIN rule_qual_kj_child c FOR KEY (parent_id) -> p (id);
+                                  ^
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+DROP TABLE rule_qual_kj_log, rule_qual_kj_child, rule_qual_kj_parent;
+-- Non-_RETURN rules on views also carry stored key-join proofs.
+CREATE TABLE rkv_parent (id int PRIMARY KEY);
+CREATE TABLE rkv_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES rkv_parent (id)
+);
+INSERT INTO rkv_parent VALUES (1);
+INSERT INTO rkv_child VALUES (10, 1);
+CREATE VIEW rkv_outer AS SELECT id FROM rkv_parent;
+CREATE RULE rkv_extra AS ON UPDATE TO rkv_outer
+DO INSTEAD SELECT count(*) FROM rkv_parent p
+-- accepted
+JOIN rkv_child c FOR KEY (parent_id) -> p (id);
+-- rejected, reason: stored rule action depends on parent having no subclasses
+CREATE TABLE rkv_subclass () INHERITS (rkv_parent);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+DROP RULE rkv_extra ON rkv_outer;
+DROP VIEW rkv_outer;
+DROP TABLE rkv_child, rkv_parent;
+-- ALTER FUNCTION and CREATE OR REPLACE FUNCTION on a function used in
+-- a stored matched-filter conjunct must revalidate dependent stored
+-- key-join proofs.  The proof's contain_volatile_functions check is
+-- decided at parse time; a later volatility, strictness, or body change
+-- can break the matched-filter contract.
+CREATE FUNCTION pf_kj_pick() RETURNS int
+    LANGUAGE plpgsql STABLE AS $$ BEGIN RETURN 1; END; $$;
+CREATE TABLE pf_kj_p (id int PRIMARY KEY);
+CREATE TABLE pf_kj_r (id int NOT NULL REFERENCES pf_kj_p (id));
+INSERT INTO pf_kj_p VALUES (1);
+INSERT INTO pf_kj_r VALUES (1);
+CREATE VIEW pf_kj_vp AS SELECT id FROM pf_kj_p WHERE id = pf_kj_pick();
+CREATE VIEW pf_kj_vr AS SELECT id FROM pf_kj_r WHERE id = pf_kj_pick();
+CREATE VIEW pf_kj_dep AS
+SELECT pf_kj_vp.id FROM pf_kj_vp
+-- accepted
+JOIN pf_kj_vr FOR KEY (id) -> pf_kj_vp (id);
+-- rejected, reason: stored proof depends on the function being non-volatile
+ALTER FUNCTION pf_kj_pick() VOLATILE;
+ERROR:  key join from referencing relation pf_kj_vr to referenced relation pf_kj_vp cannot be proven
+DETAIL:  There is no matching foreign key constraint for pf_kj_vr (id) referencing pf_kj_vp (id).
+-- rejected, reason: CREATE OR REPLACE may change body or volatility
+CREATE OR REPLACE FUNCTION pf_kj_pick() RETURNS int
+    LANGUAGE plpgsql VOLATILE AS $$ BEGIN RETURN 2; END; $$;
+ERROR:  key join from referencing relation pf_kj_vr to referenced relation pf_kj_vp cannot be proven
+DETAIL:  There is no matching foreign key constraint for pf_kj_vr (id) referencing pf_kj_vp (id).
+-- After dropping the consumer view, both DDL paths succeed.
+DROP VIEW pf_kj_dep;
+ALTER FUNCTION pf_kj_pick() VOLATILE;
+CREATE OR REPLACE FUNCTION pf_kj_pick() RETURNS int
+    LANGUAGE plpgsql VOLATILE AS $$ BEGIN RETURN 2; END; $$;
+DROP VIEW pf_kj_vp, pf_kj_vr;
+DROP TABLE pf_kj_r, pf_kj_p;
+DROP FUNCTION pf_kj_pick();
+-- A function's own SQL body must also be revalidated after replacement.
+-- The body is transformed while the old function properties are still
+-- visible, but it is stored with dependencies against the replaced function.
+CREATE FUNCTION pf_kj_self_replace_pick() RETURNS int
+    LANGUAGE sql STABLE RETURN 1;
+CREATE TABLE pf_kj_self_replace_p (id int PRIMARY KEY);
+CREATE TABLE pf_kj_self_replace_r (id int NOT NULL REFERENCES pf_kj_self_replace_p (id));
+INSERT INTO pf_kj_self_replace_p VALUES (1);
+INSERT INTO pf_kj_self_replace_r VALUES (1);
+CREATE VIEW pf_kj_self_replace_vp AS
+SELECT id FROM pf_kj_self_replace_p WHERE id = pf_kj_self_replace_pick();
+CREATE VIEW pf_kj_self_replace_vr AS
+SELECT id FROM pf_kj_self_replace_r WHERE id = pf_kj_self_replace_pick();
+-- rejected, reason: the function's own stored proof sees final VOLATILE state
+CREATE OR REPLACE FUNCTION pf_kj_self_replace_pick() RETURNS int
+    LANGUAGE sql VOLATILE
+    RETURN CASE WHEN false THEN (
+        SELECT count(*)::int
+        FROM pf_kj_self_replace_vp p
+        JOIN pf_kj_self_replace_vr r FOR KEY (id) -> p (id)
+    ) ELSE 1 END;
+ERROR:  key join from referencing relation r to referenced relation p cannot be proven
+DETAIL:  There is no matching foreign key constraint for r (id) referencing p (id).
+DROP VIEW pf_kj_self_replace_vp, pf_kj_self_replace_vr;
+DROP TABLE pf_kj_self_replace_r, pf_kj_self_replace_p;
+DROP FUNCTION pf_kj_self_replace_pick();
+-- ALTER FUNCTION must revalidate the function's own stored SQL body too.
+CREATE FUNCTION pf_kj_self_alter_pick() RETURNS int
+    LANGUAGE sql STABLE RETURN 1;
+CREATE TABLE pf_kj_self_alter_p (id int PRIMARY KEY);
+CREATE TABLE pf_kj_self_alter_r (id int NOT NULL REFERENCES pf_kj_self_alter_p (id));
+INSERT INTO pf_kj_self_alter_p VALUES (1);
+INSERT INTO pf_kj_self_alter_r VALUES (1);
+CREATE VIEW pf_kj_self_alter_vp AS
+SELECT id FROM pf_kj_self_alter_p WHERE id = pf_kj_self_alter_pick();
+CREATE VIEW pf_kj_self_alter_vr AS
+SELECT id FROM pf_kj_self_alter_r WHERE id = pf_kj_self_alter_pick();
+-- accepted
+CREATE OR REPLACE FUNCTION pf_kj_self_alter_pick() RETURNS int
+    LANGUAGE sql STABLE
+    RETURN CASE WHEN false THEN (
+        SELECT count(*)::int
+        FROM pf_kj_self_alter_vp p
+        JOIN pf_kj_self_alter_vr r FOR KEY (id) -> p (id)
+    ) ELSE 1 END;
+-- rejected, reason: the function's own stored proof sees final VOLATILE state
+ALTER FUNCTION pf_kj_self_alter_pick() VOLATILE;
+ERROR:  key join from referencing relation r to referenced relation p cannot be proven
+DETAIL:  There is no matching foreign key constraint for r (id) referencing p (id).
+CREATE OR REPLACE FUNCTION pf_kj_self_alter_pick() RETURNS int
+    LANGUAGE sql STABLE RETURN 1;
+DROP VIEW pf_kj_self_alter_vp, pf_kj_self_alter_vr;
+DROP TABLE pf_kj_self_alter_r, pf_kj_self_alter_p;
+DROP FUNCTION pf_kj_self_alter_pick();
+-- CoerceViaIO proof filters must depend on hidden type I/O functions too.
+-- Otherwise ALTER FUNCTION can make a stored proof stale without revalidation.
+SET client_min_messages = warning;
+CREATE TYPE pf_kj_io_type;
+CREATE FUNCTION pf_kj_io_type_in(cstring)
+RETURNS pf_kj_io_type
+LANGUAGE internal STRICT IMMUTABLE AS 'int4in';
+CREATE FUNCTION pf_kj_io_type_out(pf_kj_io_type)
+RETURNS cstring
+LANGUAGE internal STRICT IMMUTABLE AS 'int4out';
+CREATE TYPE pf_kj_io_type (
+    input = pf_kj_io_type_in,
+    output = pf_kj_io_type_out,
+    like = int4
+);
+RESET client_min_messages;
+CREATE CAST (pf_kj_io_type AS integer) WITH INOUT;
+CREATE TABLE pf_kj_io_p (id int PRIMARY KEY);
+CREATE TABLE pf_kj_io_r (id int NOT NULL REFERENCES pf_kj_io_p (id));
+INSERT INTO pf_kj_io_p VALUES (1), (2);
+INSERT INTO pf_kj_io_r VALUES (1);
+CREATE VIEW pf_kj_io_vp AS
+SELECT id FROM pf_kj_io_p WHERE id = ('1'::pf_kj_io_type)::integer;
+CREATE VIEW pf_kj_io_vr AS
+SELECT id FROM pf_kj_io_r WHERE id = ('1'::pf_kj_io_type)::integer;
+CREATE VIEW pf_kj_io_dep AS
+SELECT pf_kj_io_vp.id FROM pf_kj_io_vp
+-- accepted
+JOIN pf_kj_io_vr FOR KEY (id) -> pf_kj_io_vp (id);
+-- rejected, reason: stored proof depends on CoerceViaIO output being non-volatile
+ALTER FUNCTION pf_kj_io_type_out(pf_kj_io_type) VOLATILE;
+ERROR:  key join from referencing relation pf_kj_io_vr to referenced relation pf_kj_io_vp cannot be proven
+DETAIL:  There is no matching foreign key constraint for pf_kj_io_vr (id) referencing pf_kj_io_vp (id).
+-- rejected, reason: CREATE OR REPLACE may change CoerceViaIO output volatility
+CREATE OR REPLACE FUNCTION pf_kj_io_type_out(pf_kj_io_type)
+RETURNS cstring
+LANGUAGE internal STRICT VOLATILE AS 'int4out';
+ERROR:  key join from referencing relation pf_kj_io_vr to referenced relation pf_kj_io_vp cannot be proven
+DETAIL:  There is no matching foreign key constraint for pf_kj_io_vr (id) referencing pf_kj_io_vp (id).
+-- After dropping the consumer view, both DDL paths succeed.
+DROP VIEW pf_kj_io_dep;
+ALTER FUNCTION pf_kj_io_type_out(pf_kj_io_type) VOLATILE;
+CREATE OR REPLACE FUNCTION pf_kj_io_type_out(pf_kj_io_type)
+RETURNS cstring
+LANGUAGE internal STRICT VOLATILE AS 'int4out';
+DROP VIEW pf_kj_io_vp, pf_kj_io_vr;
+DROP TABLE pf_kj_io_r, pf_kj_io_p;
+DROP CAST (pf_kj_io_type AS integer);
+SET client_min_messages = warning;
+DROP TYPE pf_kj_io_type CASCADE;
+RESET client_min_messages;
+-- New-style SQL function bodies store analyzed key-join proofs, so DDL that
+-- changes a consumed proof dependency must revalidate those stored bodies.
+CREATE TABLE sqlbody_kj_view_parent (id int PRIMARY KEY);
+CREATE TABLE sqlbody_kj_view_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES sqlbody_kj_view_parent (id)
+);
+INSERT INTO sqlbody_kj_view_parent VALUES (1), (2);
+INSERT INTO sqlbody_kj_view_child VALUES (10, 1), (20, 2);
+CREATE VIEW sqlbody_kj_view_parent_v AS
+SELECT id FROM sqlbody_kj_view_parent;
+CREATE FUNCTION sqlbody_kj_view_dep() RETURNS TABLE(child_id int)
+LANGUAGE sql
+BEGIN ATOMIC
+    SELECT c.id AS child_id
+    FROM sqlbody_kj_view_parent_v p
+    -- accepted
+    JOIN sqlbody_kj_view_child c FOR KEY (parent_id) -> p (id)
+    ORDER BY c.id;
+END;
+-- rejected, reason: stored SQL function body depends on referenced rows
+-- being preserved by the view.
+CREATE OR REPLACE VIEW sqlbody_kj_view_parent_v AS
+SELECT id FROM sqlbody_kj_view_parent WHERE id = 1;
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+DETAIL:  Not every c (parent_id) value can be proven to have a matching p row. Referenced relation p has a filter that is not matched by referencing relation c.
+SELECT * FROM sqlbody_kj_view_dep();
+ child_id 
+----------
+       10
+       20
+(2 rows)
+
+DROP FUNCTION sqlbody_kj_view_dep();
+DROP VIEW sqlbody_kj_view_parent_v;
+DROP TABLE sqlbody_kj_view_child, sqlbody_kj_view_parent;
+-- Stored SQL function bodies must also be revalidated when FK proof flags
+-- change.
+CREATE TABLE sqlbody_kj_fk_parent (id int PRIMARY KEY);
+CREATE TABLE sqlbody_kj_fk_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL,
+    CONSTRAINT sqlbody_kj_fk_child_parent_id_fkey
+        FOREIGN KEY (parent_id) REFERENCES sqlbody_kj_fk_parent (id)
+);
+INSERT INTO sqlbody_kj_fk_parent VALUES (1);
+INSERT INTO sqlbody_kj_fk_child VALUES (10, 1);
+CREATE FUNCTION sqlbody_kj_fk_dep() RETURNS TABLE(child_id int)
+LANGUAGE sql
+BEGIN ATOMIC
+    SELECT c.id AS child_id
+    FROM sqlbody_kj_fk_parent p
+    -- accepted
+    JOIN sqlbody_kj_fk_child c FOR KEY (parent_id) -> p (id);
+END;
+-- rejected, reason: stored SQL function body depends on the FK proof
+ALTER TABLE sqlbody_kj_fk_child
+    ALTER CONSTRAINT sqlbody_kj_fk_child_parent_id_fkey NOT ENFORCED;
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+-- rejected, reason: stored SQL function body depends on the non-deferrable FK proof
+ALTER TABLE sqlbody_kj_fk_child
+    ALTER CONSTRAINT sqlbody_kj_fk_child_parent_id_fkey
+    DEFERRABLE INITIALLY IMMEDIATE;
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+SELECT * FROM sqlbody_kj_fk_dep();
+ child_id 
+----------
+       10
+(1 row)
+
+DROP FUNCTION sqlbody_kj_fk_dep();
+DROP TABLE sqlbody_kj_fk_child, sqlbody_kj_fk_parent;
+-- Revalidating a SQL function body must reject producer replacement when
+-- replay would require a new proof dependency.  Explicit recreation stores
+-- the new proof source and stops blocking the old one.
+CREATE TABLE sqlbody_kj_refresh_parent_a (id int PRIMARY KEY);
+CREATE TABLE sqlbody_kj_refresh_parent_b (id int PRIMARY KEY);
+CREATE TABLE sqlbody_kj_refresh_child
+(
+    id int PRIMARY KEY,
+    ref int NOT NULL,
+    CONSTRAINT sqlbody_kj_refresh_child_ref_a_fkey
+        FOREIGN KEY (ref) REFERENCES sqlbody_kj_refresh_parent_a (id),
+    CONSTRAINT sqlbody_kj_refresh_child_ref_b_fkey
+        FOREIGN KEY (ref) REFERENCES sqlbody_kj_refresh_parent_b (id)
+);
+INSERT INTO sqlbody_kj_refresh_parent_a VALUES (1);
+INSERT INTO sqlbody_kj_refresh_parent_b VALUES (1);
+INSERT INTO sqlbody_kj_refresh_child VALUES (10, 1);
+CREATE VIEW sqlbody_kj_refresh_parent_v AS
+SELECT id FROM sqlbody_kj_refresh_parent_a;
+CREATE FUNCTION sqlbody_kj_refresh_dep() RETURNS TABLE(child_id int)
+LANGUAGE sql
+BEGIN ATOMIC
+    SELECT c.id AS child_id
+    FROM sqlbody_kj_refresh_parent_v p
+    -- accepted
+    JOIN sqlbody_kj_refresh_child c FOR KEY (ref) -> p (id);
+END;
+-- rejected, reason: replay would require the parent_b FK proof
+CREATE OR REPLACE VIEW sqlbody_kj_refresh_parent_v AS
+SELECT id FROM sqlbody_kj_refresh_parent_b;
+ERROR:  stored key join proof would require new dependencies
+DROP FUNCTION sqlbody_kj_refresh_dep();
+CREATE OR REPLACE VIEW sqlbody_kj_refresh_parent_v AS
+SELECT id FROM sqlbody_kj_refresh_parent_b;
+CREATE FUNCTION sqlbody_kj_refresh_dep() RETURNS TABLE(child_id int)
+LANGUAGE sql
+BEGIN ATOMIC
+    SELECT c.id AS child_id
+    FROM sqlbody_kj_refresh_parent_v p
+    -- accepted
+    JOIN sqlbody_kj_refresh_child c FOR KEY (ref) -> p (id);
+END;
+SELECT * FROM sqlbody_kj_refresh_dep();
+ child_id 
+----------
+       10
+(1 row)
+
+ALTER TABLE sqlbody_kj_refresh_child
+    DROP CONSTRAINT sqlbody_kj_refresh_child_ref_a_fkey;
+-- rejected, reason: recreated SQL function body depends on the new FK proof
+ALTER TABLE sqlbody_kj_refresh_child
+    DROP CONSTRAINT sqlbody_kj_refresh_child_ref_b_fkey;
+ERROR:  cannot drop constraint sqlbody_kj_refresh_child_ref_b_fkey on table sqlbody_kj_refresh_child because other objects depend on it
+DETAIL:  function sqlbody_kj_refresh_dep() depends on constraint sqlbody_kj_refresh_child_ref_b_fkey on table sqlbody_kj_refresh_child
+HINT:  Use DROP ... CASCADE to drop the dependent objects too.
+SELECT * FROM sqlbody_kj_refresh_dep();
+ child_id 
+----------
+       10
+(1 row)
+
+DROP FUNCTION sqlbody_kj_refresh_dep();
+DROP VIEW sqlbody_kj_refresh_parent_v;
+DROP TABLE sqlbody_kj_refresh_child,
+           sqlbody_kj_refresh_parent_b,
+           sqlbody_kj_refresh_parent_a;
+-- Producer-side DDL may affect key-join proofs stored in dependent
+-- objects owned by other roles.  If those proofs still validate and
+-- require no new dependencies, the producer owner can replace the
+-- producer view without owning the dependent view.
+CREATE ROLE regress_kj_cross_owner_producer;
+CREATE ROLE regress_kj_cross_owner_consumer;
+CREATE SCHEMA kj_cross_owner_p AUTHORIZATION regress_kj_cross_owner_producer;
+CREATE SCHEMA kj_cross_owner_c AUTHORIZATION regress_kj_cross_owner_consumer;
+SET ROLE regress_kj_cross_owner_producer;
+CREATE TABLE kj_cross_owner_p.parent (id int PRIMARY KEY);
+CREATE TABLE kj_cross_owner_p.child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES kj_cross_owner_p.parent (id)
+);
+CREATE VIEW kj_cross_owner_p.parent_v AS
+SELECT id FROM kj_cross_owner_p.parent;
+GRANT USAGE ON SCHEMA kj_cross_owner_p TO regress_kj_cross_owner_consumer;
+GRANT SELECT ON kj_cross_owner_p.parent_v TO regress_kj_cross_owner_consumer;
+GRANT SELECT ON kj_cross_owner_p.child TO regress_kj_cross_owner_consumer;
+RESET ROLE;
+SET ROLE regress_kj_cross_owner_consumer;
+CREATE VIEW kj_cross_owner_c.dep AS
+SELECT p.id AS parent_id, c.id AS child_id
+FROM kj_cross_owner_p.parent_v p
+-- accepted
+JOIN kj_cross_owner_p.child c FOR KEY (parent_id) -> p (id);
+RESET ROLE;
+SET ROLE regress_kj_cross_owner_producer;
+CREATE OR REPLACE VIEW kj_cross_owner_p.parent_v AS
+SELECT id FROM kj_cross_owner_p.parent;
+RESET ROLE;
+DROP SCHEMA kj_cross_owner_c CASCADE;
+NOTICE:  drop cascades to view kj_cross_owner_c.dep
+DROP SCHEMA kj_cross_owner_p CASCADE;
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table kj_cross_owner_p.parent
+drop cascades to table kj_cross_owner_p.child
+drop cascades to view kj_cross_owner_p.parent_v
+DROP ROLE regress_kj_cross_owner_consumer;
+DROP ROLE regress_kj_cross_owner_producer;
+-- A key join can preserve referenced-side uniqueness only when the
+-- referencing side is unique.  Stored proofs that consume the projected
+-- uniqueness must depend on that referencing-side unique proof.
+CREATE TABLE output_unique_kj_parent (id int PRIMARY KEY);
+CREATE TABLE output_unique_kj_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL,
+    CONSTRAINT output_unique_kj_child_parent_id_key UNIQUE (parent_id),
+    CONSTRAINT output_unique_kj_child_parent_id_fkey
+        FOREIGN KEY (parent_id) REFERENCES output_unique_kj_parent (id)
+);
+CREATE TABLE output_unique_kj_grandchild
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES output_unique_kj_parent (id)
+);
+INSERT INTO output_unique_kj_parent VALUES (1), (2);
+INSERT INTO output_unique_kj_child VALUES (10, 1);
+INSERT INTO output_unique_kj_grandchild VALUES (100, 1), (200, 2);
+CREATE VIEW output_unique_kj_view AS
+SELECT q.parent_id, q.child_id, gc.id AS grandchild_id
+FROM (
+    SELECT p.id AS parent_id, c.id AS child_id
+    FROM output_unique_kj_parent p
+    LEFT JOIN output_unique_kj_child c FOR KEY (parent_id) -> p (id)
+) q
+-- accepted because output_unique_kj_child.parent_id is unique
+JOIN output_unique_kj_grandchild gc FOR KEY (parent_id) -> q (parent_id);
+-- rejected, reason: the stored view depends on the referencing-side uniqueness
+-- that preserved q.parent_id uniqueness.
+ALTER TABLE output_unique_kj_child
+    DROP CONSTRAINT output_unique_kj_child_parent_id_key;
+ERROR:  cannot drop constraint output_unique_kj_child_parent_id_key on table output_unique_kj_child because other objects depend on it
+DETAIL:  view output_unique_kj_view depends on constraint output_unique_kj_child_parent_id_key on table output_unique_kj_child
+HINT:  Use DROP ... CASCADE to drop the dependent objects too.
+-- After dropping the consumer view, the same DDL succeeds and the raw nested
+-- key join is no longer provable.
+DROP VIEW output_unique_kj_view;
+ALTER TABLE output_unique_kj_child
+    DROP CONSTRAINT output_unique_kj_child_parent_id_key;
+SELECT q.parent_id, q.child_id, gc.id AS grandchild_id
+FROM (
+    SELECT p.id AS parent_id, c.id AS child_id
+    FROM output_unique_kj_parent p
+    LEFT JOIN output_unique_kj_child c FOR KEY (parent_id) -> p (id)
+) q
+JOIN output_unique_kj_grandchild gc
+    FOR KEY (parent_id) -> q (parent_id);
+ERROR:  key join from referencing relation gc to referenced relation q cannot be proven
+LINE 8:     FOR KEY (parent_id) -> q (parent_id);
+            ^
+DETAIL:  Referenced columns q (parent_id) are not proven unique. A preceding join may duplicate rows from referenced relation q.
+DROP TABLE output_unique_kj_grandchild,
+           output_unique_kj_child,
+           output_unique_kj_parent;
+-- Referenced-side FK facts survive outer null-extension as nullable FK facts.
+CREATE TABLE output_fk_null_ext_gp (id int PRIMARY KEY);
+CREATE TABLE output_fk_null_ext_parent
+(
+    id int PRIMARY KEY,
+    gp_id int NOT NULL REFERENCES output_fk_null_ext_gp (id)
+);
+CREATE TABLE output_fk_null_ext_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES output_fk_null_ext_parent (id)
+);
+INSERT INTO output_fk_null_ext_gp VALUES (1), (2);
+INSERT INTO output_fk_null_ext_parent VALUES (10, 1), (20, 2);
+INSERT INTO output_fk_null_ext_child VALUES (100, 10);
+-- Control: a non-null-extending referenced side already exported the FK fact.
+SELECT q.parent_id, q.gp_id, q.child_id, g.id AS gp_match
+FROM (
+    SELECT p.id AS parent_id, p.gp_id, c.id AS child_id
+    FROM output_fk_null_ext_parent p
+    LEFT JOIN output_fk_null_ext_child c FOR KEY (parent_id) -> p (id)
+) q
+LEFT JOIN output_fk_null_ext_gp g FOR KEY (id) <- q (gp_id)
+ORDER BY q.parent_id, q.child_id, g.id;
+ parent_id | gp_id | child_id | gp_match 
+-----------+-------+----------+----------
+        10 |     1 |      100 |        1
+        20 |     2 |          |        2
+(2 rows)
+
+-- A FULL JOIN can null-extend the referenced side, but non-null FK values
+-- projected from that side still have FK-backed containment.
+SELECT q.parent_id, q.gp_id, q.child_id, g.id AS gp_match
+FROM (
+    SELECT p.id AS parent_id, p.gp_id, c.id AS child_id
+    FROM output_fk_null_ext_parent p
+    FULL JOIN output_fk_null_ext_child c FOR KEY (parent_id) -> p (id)
+) q
+LEFT JOIN output_fk_null_ext_gp g FOR KEY (id) <- q (gp_id)
+ORDER BY q.parent_id, q.child_id, g.id;
+ parent_id | gp_id | child_id | gp_match 
+-----------+-------+----------+----------
+        10 |     1 |      100 |        1
+        20 |     2 |          |        2
+(2 rows)
+
+-- The FK fact survived, but not the not-null fact, so an inner key join
+-- through the same null-extended surface is still rejected.
+SELECT q.parent_id, q.gp_id, q.child_id, g.id AS gp_match
+FROM (
+    SELECT p.id AS parent_id, p.gp_id, c.id AS child_id
+    FROM output_fk_null_ext_parent p
+    FULL JOIN output_fk_null_ext_child c FOR KEY (parent_id) -> p (id)
+) q
+JOIN output_fk_null_ext_gp g FOR KEY (id) <- q (gp_id);
+ERROR:  key join from referencing relation q to referenced relation g cannot be proven
+LINE 7: JOIN output_fk_null_ext_gp g FOR KEY (id) <- q (gp_id);
+                                     ^
+DETAIL:  This inner join could filter rows from q. Referencing columns q (gp_id) can be null because a preceding outer join can null-extend the referencing side.
+DROP TABLE output_fk_null_ext_child,
+           output_fk_null_ext_parent,
+           output_fk_null_ext_gp;
+-- Stronger case: the FULL JOIN can actually produce a row whose referenced
+-- side is null-extended.  The later preserved-side key join remains valid
+-- because nullable FK values are allowed when the referencing side is preserved.
+CREATE TABLE output_fk_nullable_gp (id int PRIMARY KEY);
+CREATE TABLE output_fk_nullable_parent
+(
+    id int PRIMARY KEY,
+    gp_id int NOT NULL REFERENCES output_fk_nullable_gp (id)
+);
+CREATE TABLE output_fk_nullable_child
+(
+    id int PRIMARY KEY,
+    parent_id int REFERENCES output_fk_nullable_parent (id)
+);
+INSERT INTO output_fk_nullable_gp VALUES (1), (2);
+INSERT INTO output_fk_nullable_parent VALUES (10, 1), (20, 2);
+INSERT INTO output_fk_nullable_child VALUES (100, 10), (200, NULL);
+SELECT q.parent_id, q.gp_id, q.child_id, g.id AS gp_match
+FROM (
+    SELECT p.id AS parent_id, p.gp_id, c.id AS child_id
+    FROM output_fk_nullable_parent p
+    FULL JOIN output_fk_nullable_child c FOR KEY (parent_id) -> p (id)
+) q
+LEFT JOIN output_fk_nullable_gp g FOR KEY (id) <- q (gp_id)
+ORDER BY q.parent_id NULLS LAST, q.child_id, g.id;
+ parent_id | gp_id | child_id | gp_match 
+-----------+-------+----------+----------
+        10 |     1 |      100 |        1
+        20 |     2 |          |        2
+           |       |      200 |         
+(3 rows)
+
+DROP TABLE output_fk_nullable_child,
+           output_fk_nullable_parent,
+           output_fk_nullable_gp;
+-- New-style SQL function bodies follow the same stored dependency path.
+CREATE TABLE output_unique_sqlbody_parent (id int PRIMARY KEY);
+CREATE TABLE output_unique_sqlbody_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL,
+    CONSTRAINT output_unique_sqlbody_child_parent_id_key UNIQUE (parent_id),
+    CONSTRAINT output_unique_sqlbody_child_parent_id_fkey
+        FOREIGN KEY (parent_id) REFERENCES output_unique_sqlbody_parent (id)
+);
+CREATE TABLE output_unique_sqlbody_grandchild
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES output_unique_sqlbody_parent (id)
+);
+INSERT INTO output_unique_sqlbody_parent VALUES (1), (2);
+INSERT INTO output_unique_sqlbody_child VALUES (10, 1);
+INSERT INTO output_unique_sqlbody_grandchild VALUES (100, 1), (200, 2);
+CREATE FUNCTION output_unique_sqlbody_dep()
+RETURNS TABLE(parent_id int, child_id int, grandchild_id int)
+LANGUAGE sql
+BEGIN ATOMIC
+    SELECT q.parent_id, q.child_id, gc.id AS grandchild_id
+    FROM (
+        SELECT p.id AS parent_id, c.id AS child_id
+        FROM output_unique_sqlbody_parent p
+        LEFT JOIN output_unique_sqlbody_child c FOR KEY (parent_id) -> p (id)
+    ) q
+    -- accepted because output_unique_sqlbody_child.parent_id is unique
+    JOIN output_unique_sqlbody_grandchild gc FOR KEY (parent_id) -> q (parent_id);
+END;
+-- rejected, reason: the stored function body depends on the referencing-side
+-- uniqueness that preserved q.parent_id uniqueness.
+ALTER TABLE output_unique_sqlbody_child
+    DROP CONSTRAINT output_unique_sqlbody_child_parent_id_key;
+ERROR:  cannot drop constraint output_unique_sqlbody_child_parent_id_key on table output_unique_sqlbody_child because other objects depend on it
+DETAIL:  function output_unique_sqlbody_dep() depends on constraint output_unique_sqlbody_child_parent_id_key on table output_unique_sqlbody_child
+HINT:  Use DROP ... CASCADE to drop the dependent objects too.
+-- After dropping the consumer function, the same DDL succeeds and the raw
+-- nested key join is no longer provable.
+DROP FUNCTION output_unique_sqlbody_dep();
+ALTER TABLE output_unique_sqlbody_child
+    DROP CONSTRAINT output_unique_sqlbody_child_parent_id_key;
+SELECT q.parent_id, q.child_id, gc.id AS grandchild_id
+FROM (
+    SELECT p.id AS parent_id, c.id AS child_id
+    FROM output_unique_sqlbody_parent p
+    LEFT JOIN output_unique_sqlbody_child c FOR KEY (parent_id) -> p (id)
+) q
+JOIN output_unique_sqlbody_grandchild gc
+    FOR KEY (parent_id) -> q (parent_id);
+ERROR:  key join from referencing relation gc to referenced relation q cannot be proven
+LINE 8:     FOR KEY (parent_id) -> q (parent_id);
+            ^
+DETAIL:  Referenced columns q (parent_id) are not proven unique. A preceding join may duplicate rows from referenced relation q.
+DROP TABLE output_unique_sqlbody_grandchild,
+           output_unique_sqlbody_child,
+           output_unique_sqlbody_parent;
+-- RLS policy USING expressions store analyzed key-join proofs and must be
+-- revalidated when their proof dependencies change.
+CREATE TABLE policy_using_kj_parent (id int PRIMARY KEY);
+CREATE TABLE policy_using_kj_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES policy_using_kj_parent (id)
+);
+CREATE TABLE policy_using_kj_guarded (id int);
+INSERT INTO policy_using_kj_parent VALUES (1);
+INSERT INTO policy_using_kj_child VALUES (10, 1);
+ALTER TABLE policy_using_kj_guarded ENABLE ROW LEVEL SECURITY;
+CREATE POLICY policy_using_kj ON policy_using_kj_guarded
+USING (EXISTS (
+    SELECT 1
+    FROM policy_using_kj_parent p
+    -- accepted
+    JOIN policy_using_kj_child c FOR KEY (parent_id) -> p (id)
+));
+-- rejected, reason: stored policy USING expression depends on the FK proof
+ALTER TABLE policy_using_kj_child
+    ALTER CONSTRAINT policy_using_kj_child_parent_id_fkey NOT ENFORCED;
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+-- After dropping the policy, the same DDL succeeds.
+DROP POLICY policy_using_kj ON policy_using_kj_guarded;
+ALTER TABLE policy_using_kj_child
+    ALTER CONSTRAINT policy_using_kj_child_parent_id_fkey NOT ENFORCED;
+SELECT *
+FROM policy_using_kj_parent p
+-- rejected, reason: FK is now not enforced
+JOIN policy_using_kj_child c FOR KEY (parent_id) -> p (id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 4: JOIN policy_using_kj_child c FOR KEY (parent_id) -> p (id);
+                                     ^
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+DROP TABLE policy_using_kj_guarded,
+           policy_using_kj_child,
+           policy_using_kj_parent;
+-- RLS policy WITH CHECK expressions follow the same revalidation path.
+CREATE TABLE policy_check_kj_parent (id int PRIMARY KEY);
+CREATE TABLE policy_check_kj_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES policy_check_kj_parent (id)
+);
+CREATE TABLE policy_check_kj_guarded (id int);
+INSERT INTO policy_check_kj_parent VALUES (1);
+INSERT INTO policy_check_kj_child VALUES (10, 1);
+ALTER TABLE policy_check_kj_guarded ENABLE ROW LEVEL SECURITY;
+CREATE POLICY policy_check_kj ON policy_check_kj_guarded
+FOR INSERT
+WITH CHECK (EXISTS (
+    SELECT 1
+    FROM policy_check_kj_parent p
+    -- accepted
+    JOIN policy_check_kj_child c FOR KEY (parent_id) -> p (id)
+));
+-- rejected, reason: stored policy WITH CHECK expression depends on the FK proof
+ALTER TABLE policy_check_kj_child
+    ALTER CONSTRAINT policy_check_kj_child_parent_id_fkey NOT ENFORCED;
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+-- After dropping the policy, the same DDL succeeds.
+DROP POLICY policy_check_kj ON policy_check_kj_guarded;
+ALTER TABLE policy_check_kj_child
+    ALTER CONSTRAINT policy_check_kj_child_parent_id_fkey NOT ENFORCED;
+SELECT *
+FROM policy_check_kj_parent p
+-- rejected, reason: FK is now not enforced
+JOIN policy_check_kj_child c FOR KEY (parent_id) -> p (id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 4: JOIN policy_check_kj_child c FOR KEY (parent_id) -> p (id);
+                                     ^
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+DROP TABLE policy_check_kj_guarded,
+           policy_check_kj_child,
+           policy_check_kj_parent;
+-- Old-style quoted SQL functions do not have stored prosqlbody proofs; they
+-- still revalidate when their text is parsed for execution.
+CREATE TABLE sqlbody_kj_old_parent (id int PRIMARY KEY);
+CREATE TABLE sqlbody_kj_old_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES sqlbody_kj_old_parent (id)
+);
+INSERT INTO sqlbody_kj_old_parent VALUES (1), (2);
+INSERT INTO sqlbody_kj_old_child VALUES (10, 1), (20, 2);
+CREATE VIEW sqlbody_kj_old_parent_v AS
+SELECT id FROM sqlbody_kj_old_parent;
+CREATE FUNCTION sqlbody_kj_old_dep() RETURNS TABLE(child_id int)
+LANGUAGE sql SECURITY DEFINER AS $$
+    SELECT c.id AS child_id
+    FROM sqlbody_kj_old_parent_v p
+    JOIN sqlbody_kj_old_child c FOR KEY (parent_id) -> p (id)
+    ORDER BY c.id
+$$;
+SELECT * FROM sqlbody_kj_old_dep();
+ child_id 
+----------
+       10
+       20
+(2 rows)
+
+CREATE OR REPLACE VIEW sqlbody_kj_old_parent_v AS
+SELECT id FROM sqlbody_kj_old_parent WHERE id = 1;
+-- rejected, reason: old-style function text is revalidated at execution
+SELECT * FROM sqlbody_kj_old_dep();
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 4:     JOIN sqlbody_kj_old_child c FOR KEY (parent_id) -> p (id...
+                                        ^
+DETAIL:  Not every c (parent_id) value can be proven to have a matching p row. Referenced relation p has a filter that is not matched by referencing relation c.
+QUERY:  
+    SELECT c.id AS child_id
+    FROM sqlbody_kj_old_parent_v p
+    JOIN sqlbody_kj_old_child c FOR KEY (parent_id) -> p (id)
+    ORDER BY c.id
+
+CONTEXT:  SQL function "sqlbody_kj_old_dep" statement 1
+DROP FUNCTION sqlbody_kj_old_dep();
+DROP VIEW sqlbody_kj_old_parent_v;
+DROP TABLE sqlbody_kj_old_child, sqlbody_kj_old_parent;
+-- Query-level uniqueness on the referenced side must not be paired with row
+-- coverage from an unrelated relation that happens to expose the same column
+-- number.
+CREATE TABLE key_join_wrong_coverage_parent
+(
+    id int PRIMARY KEY
+);
+CREATE TABLE key_join_wrong_coverage_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES key_join_wrong_coverage_parent (id)
+);
+CREATE TABLE key_join_wrong_coverage_other
+(
+    id int PRIMARY KEY
+);
+INSERT INTO key_join_wrong_coverage_parent VALUES (1), (2);
+INSERT INTO key_join_wrong_coverage_child VALUES (10, 2);
+INSERT INTO key_join_wrong_coverage_other VALUES (1);
+SELECT c.id, c.parent_id, o.id AS other_id
+FROM key_join_wrong_coverage_child c
+-- rejected, reason: referenced operand does not cover parent_id = 2
+JOIN (SELECT id FROM key_join_wrong_coverage_other GROUP BY id) o
+    FOR KEY (id) <- c (parent_id);
+ERROR:  key join from referencing relation c to referenced relation o cannot be proven
+LINE 5:     FOR KEY (id) <- c (parent_id);
+            ^
+DETAIL:  Not every c (parent_id) value can be proven to have a matching o row. Referenced relation o is not proven to contain every referenced key row.
+DROP TABLE key_join_wrong_coverage_child,
+           key_join_wrong_coverage_other,
+           key_join_wrong_coverage_parent;
+-- Referenced-side uniqueness and row coverage must use the same key identity
+-- as the FK proof.  When an older alternate unique index appears before the
+-- primary-key index, proof selection must skip it and continue to the primary
+-- key instead of asserting.
+CREATE FUNCTION key_join_refidx_eq(a int, b int) RETURNS boolean
+LANGUAGE sql IMMUTABLE STRICT AS $$ SELECT a = b $$;
+CREATE OPERATOR =# (
+    LEFTARG = int,
+    RIGHTARG = int,
+    FUNCTION = key_join_refidx_eq,
+    COMMUTATOR = =#,
+    RESTRICT = eqsel,
+    JOIN = eqjoinsel,
+    MERGES
+);
+CREATE OPERATOR CLASS key_join_refidx_int4_ops
+FOR TYPE int USING btree AS
+    OPERATOR 3 =# (int, int),
+    FUNCTION 1 btint4cmp(int, int);
+CREATE TABLE key_join_refidx_parent
+(
+    id int NOT NULL
+);
+CREATE UNIQUE INDEX key_join_refidx_parent_alt_idx
+    ON key_join_refidx_parent USING btree (id key_join_refidx_int4_ops);
+ALTER TABLE key_join_refidx_parent ADD PRIMARY KEY (id);
+CREATE TABLE key_join_refidx_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES key_join_refidx_parent
+);
+INSERT INTO key_join_refidx_parent VALUES (1), (2);
+INSERT INTO key_join_refidx_child VALUES (10, 1), (20, 2);
+SELECT c.id AS child_id, p.id AS parent_id
+FROM key_join_refidx_parent p
+JOIN key_join_refidx_child c FOR KEY (parent_id) -> p (id)
+ORDER BY c.id;
+ child_id | parent_id 
+----------+-----------
+       10 |         1
+       20 |         2
+(2 rows)
+
+DROP TABLE key_join_refidx_child,
+           key_join_refidx_parent;
+DROP OPERATOR CLASS key_join_refidx_int4_ops USING btree;
+DROP OPERATOR FAMILY key_join_refidx_int4_ops USING btree;
+DROP OPERATOR =# (int, int);
+DROP FUNCTION key_join_refidx_eq(int, int);
+-- A duplicate-column unique index that appears before the real FK target
+-- index must be skipped, not asserted over while matching selected columns.
+CREATE TABLE key_join_dupidx_parent
+(
+    id int NOT NULL,
+    other_id int NOT NULL
+);
+CREATE UNIQUE INDEX key_join_dupidx_parent_dup_idx
+    ON key_join_dupidx_parent (id, id);
+CREATE UNIQUE INDEX key_join_dupidx_parent_real_idx
+    ON key_join_dupidx_parent (id, other_id);
+CREATE TABLE key_join_dupidx_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL,
+    parent_other_id int NOT NULL,
+    FOREIGN KEY (parent_id, parent_other_id)
+        REFERENCES key_join_dupidx_parent (id, other_id)
+);
+INSERT INTO key_join_dupidx_parent VALUES (1, 10), (2, 20);
+INSERT INTO key_join_dupidx_child VALUES (101, 1, 10), (202, 2, 20);
+SELECT c.id AS child_id, p.id AS parent_id, p.other_id
+FROM key_join_dupidx_parent p
+JOIN key_join_dupidx_child c
+    FOR KEY (parent_id, parent_other_id) -> p (id, other_id)
+ORDER BY c.id;
+ child_id | parent_id | other_id 
+----------+-----------+----------
+      101 |         1 |       10
+      202 |         2 |       20
+(2 rows)
+
+DROP TABLE key_join_dupidx_child,
+           key_join_dupidx_parent;
+-- Referencing-side uniqueness cannot be projected through a key join unless it
+-- uses the same key-position identity as the FK proof.
+CREATE FUNCTION key_join_refuniq_bucket_cmp(a int, b int) RETURNS int
+LANGUAGE sql IMMUTABLE STRICT AS
+$$ SELECT CASE WHEN a / 10 < b / 10 THEN -1
+               WHEN a / 10 > b / 10 THEN 1
+               ELSE 0 END $$;
+CREATE FUNCTION key_join_refuniq_bucket_eq(a int, b int) RETURNS boolean
+LANGUAGE sql IMMUTABLE STRICT AS $$ SELECT a / 10 = b / 10 $$;
+CREATE OPERATOR =# (
+    LEFTARG = int,
+    RIGHTARG = int,
+    FUNCTION = key_join_refuniq_bucket_eq,
+    COMMUTATOR = =#,
+    RESTRICT = eqsel,
+    JOIN = eqjoinsel,
+    MERGES
+);
+CREATE OPERATOR CLASS key_join_refuniq_bucket_int4_ops
+FOR TYPE int USING btree AS
+    OPERATOR 3 =# (int, int),
+    FUNCTION 1 key_join_refuniq_bucket_cmp(int, int);
+CREATE TABLE key_join_refuniq_parent
+(
+    id int NOT NULL
+);
+CREATE UNIQUE INDEX key_join_refuniq_parent_idx
+    ON key_join_refuniq_parent USING btree (id key_join_refuniq_bucket_int4_ops);
+CREATE TABLE key_join_refuniq_child
+(
+    id int PRIMARY KEY,
+    parent_id int UNIQUE NOT NULL REFERENCES key_join_refuniq_parent (id)
+);
+CREATE TABLE key_join_refuniq_grandchild
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES key_join_refuniq_parent (id)
+);
+INSERT INTO key_join_refuniq_parent VALUES (10), (20);
+INSERT INTO key_join_refuniq_child VALUES (1, 10), (2, 11), (3, 20);
+INSERT INTO key_join_refuniq_grandchild VALUES (101, 10), (102, 20);
+SELECT p.id AS parent_key, c.id AS child_id, c.parent_id
+FROM key_join_refuniq_parent p
+LEFT JOIN key_join_refuniq_child c FOR KEY (parent_id) -> p (id)
+ORDER BY 1, 2;
+ parent_key | child_id | parent_id 
+------------+----------+-----------
+         10 |        1 |        10
+         10 |        2 |        11
+         20 |        3 |        20
+(3 rows)
+
+SELECT q.parent_key, q.child_id, g.id AS grandchild_id
+FROM (
+    SELECT p.id AS parent_key, c.id AS child_id, c.parent_id
+    FROM key_join_refuniq_parent p
+    LEFT JOIN key_join_refuniq_child c FOR KEY (parent_id) -> p (id)
+) q
+JOIN key_join_refuniq_grandchild g FOR KEY (parent_id) -> q (parent_key)
+ORDER BY q.parent_key, q.child_id, g.id;
+ERROR:  key join from referencing relation g to referenced relation q cannot be proven
+LINE 7: JOIN key_join_refuniq_grandchild g FOR KEY (parent_id) -> q ...
+                                           ^
+DETAIL:  Referenced columns q (parent_key) are not proven unique. A preceding join may duplicate rows from referenced relation q.
+DROP TABLE key_join_refuniq_grandchild,
+           key_join_refuniq_child,
+           key_join_refuniq_parent;
+DROP OPERATOR CLASS key_join_refuniq_bucket_int4_ops USING btree;
+DROP OPERATOR FAMILY key_join_refuniq_bucket_int4_ops USING btree;
+DROP OPERATOR =# (int, int);
+DROP FUNCTION key_join_refuniq_bucket_cmp(int, int);
+DROP FUNCTION key_join_refuniq_bucket_eq(int, int);
+-- Set-returning equality operator functions are not usable proof operators.
+CREATE FUNCTION key_join_srf_eq(a int, b int) RETURNS SETOF boolean
+LANGUAGE sql IMMUTABLE STRICT AS $$ SELECT a = b $$;
+CREATE FUNCTION key_join_srf_cmp(a int, b int) RETURNS int
+LANGUAGE sql IMMUTABLE STRICT AS $$ SELECT btint4cmp(a, b) $$;
+CREATE OPERATOR =# (
+    LEFTARG = int,
+    RIGHTARG = int,
+    FUNCTION = key_join_srf_eq,
+    COMMUTATOR = =#,
+    RESTRICT = eqsel,
+    JOIN = eqjoinsel,
+    MERGES
+);
+CREATE OPERATOR CLASS key_join_srf_int4_ops
+FOR TYPE int USING btree AS
+    OPERATOR 1 < (int, int),
+    OPERATOR 2 <= (int, int),
+    OPERATOR 3 =# (int, int),
+    OPERATOR 4 >= (int, int),
+    OPERATOR 5 > (int, int),
+    FUNCTION 1 key_join_srf_cmp(int, int);
+CREATE TABLE key_join_srf_parent
+(
+    id int NOT NULL
+);
+CREATE UNIQUE INDEX key_join_srf_parent_idx
+    ON key_join_srf_parent USING btree (id key_join_srf_int4_ops);
+CREATE TABLE key_join_srf_child
+(
+    parent_id int NOT NULL REFERENCES key_join_srf_parent (id)
+);
+SELECT *
+FROM key_join_srf_parent p
+JOIN key_join_srf_child c FOR KEY (parent_id) -> p (id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 3: JOIN key_join_srf_child c FOR KEY (parent_id) -> p (id);
+                                  ^
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+DROP TABLE key_join_srf_child,
+           key_join_srf_parent;
+DROP OPERATOR CLASS key_join_srf_int4_ops USING btree;
+DROP OPERATOR FAMILY key_join_srf_int4_ops USING btree;
+DROP OPERATOR =# (int, int);
+DROP FUNCTION key_join_srf_eq(int, int);
+DROP FUNCTION key_join_srf_cmp(int, int);
+-- Generated key-join quals must require SELECT on both key columns.
+CREATE ROLE regress_key_join_priv_user;
+CREATE SCHEMA key_join_priv;
+CREATE TABLE key_join_priv.parent
+(
+    id int PRIMARY KEY,
+    visible text
+);
+CREATE TABLE key_join_priv.child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES key_join_priv.parent (id),
+    visible text
+);
+INSERT INTO key_join_priv.parent VALUES (1, 'parent-one');
+INSERT INTO key_join_priv.child VALUES (10, 1, 'child-one');
+GRANT USAGE ON SCHEMA key_join_priv TO regress_key_join_priv_user;
+GRANT SELECT (visible) ON key_join_priv.parent TO regress_key_join_priv_user;
+GRANT SELECT (visible) ON key_join_priv.child TO regress_key_join_priv_user;
+SET ROLE regress_key_join_priv_user;
+SELECT p.visible AS parent_visible, c.visible AS child_visible
+FROM key_join_priv.parent p
+JOIN key_join_priv.child c FOR KEY (parent_id) -> p (id);
+ERROR:  permission denied for table parent
+RESET ROLE;
+GRANT SELECT (id) ON key_join_priv.parent TO regress_key_join_priv_user;
+SET ROLE regress_key_join_priv_user;
+SELECT p.visible AS parent_visible, c.visible AS child_visible
+FROM key_join_priv.parent p
+JOIN key_join_priv.child c FOR KEY (parent_id) -> p (id);
+ERROR:  permission denied for table child
+RESET ROLE;
+GRANT SELECT (parent_id) ON key_join_priv.child TO regress_key_join_priv_user;
+SET ROLE regress_key_join_priv_user;
+SELECT p.visible AS parent_visible, c.visible AS child_visible
+FROM key_join_priv.parent p
+JOIN key_join_priv.child c FOR KEY (parent_id) -> p (id);
+ parent_visible | child_visible 
+----------------+---------------
+ parent-one     | child-one
+(1 row)
+
+RESET ROLE;
+DROP SCHEMA key_join_priv CASCADE;
+NOTICE:  drop cascades to 2 other objects
+DETAIL:  drop cascades to table key_join_priv.parent
+drop cascades to table key_join_priv.child
+DROP ROLE regress_key_join_priv_user;
+-- Key-join proof metadata must not affect query identifiers.  The same
+-- executable query should keep its queryId if an equivalent FK proof is
+-- dropped and recreated with new catalog OIDs.
+CREATE FUNCTION pg_temp.key_join_query_id(query_text text) RETURNS bigint
+LANGUAGE plpgsql AS $$
+DECLARE
+    line text;
+BEGIN
+    FOR line IN EXECUTE 'EXPLAIN (VERBOSE, FORMAT TEXT) ' || query_text
+    LOOP
+        IF line ~ 'Query Identifier:' THEN
+            RETURN regexp_replace(line, '.*Query Identifier:\s*(-?\d+).*', '\1')::bigint;
+        END IF;
+    END LOOP;
+    RAISE EXCEPTION 'Query Identifier not found in EXPLAIN output';
+END;
+$$;
+SET compute_query_id = on;
+CREATE TABLE key_join_queryid_parent
+(
+    id int PRIMARY KEY
+);
+CREATE TABLE key_join_queryid_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES key_join_queryid_parent (id)
+);
+SELECT pg_temp.key_join_query_id($$
+SELECT *
+FROM key_join_queryid_parent p
+JOIN key_join_queryid_child c FOR KEY (parent_id) -> p (id);
+$$) AS qid \gset
+ALTER TABLE key_join_queryid_child DROP CONSTRAINT key_join_queryid_child_parent_id_fkey;
+ALTER TABLE key_join_queryid_child
+    ADD CONSTRAINT key_join_queryid_child_parent_id_fkey
+    FOREIGN KEY (parent_id) REFERENCES key_join_queryid_parent (id);
+SELECT pg_temp.key_join_query_id($$
+SELECT *
+FROM key_join_queryid_parent p
+JOIN key_join_queryid_child c FOR KEY (parent_id) -> p (id);
+$$) = :'qid'::bigint AS same_query_id_after_fk_recreate;
+ same_query_id_after_fk_recreate 
+---------------------------------
+ t
+(1 row)
+
+RESET compute_query_id;
+DROP TABLE key_join_queryid_child, key_join_queryid_parent;
+-- ============================================================================
+-- Precise diagnostics for unprovable key joins: the error states which proof
+-- requirement is missing, and when the evidence existed but was discarded
+-- mid-query, why (and where, when locatable).
+-- ============================================================================
+CREATE SCHEMA key_join_diag;
+SET search_path = key_join_diag;
+CREATE TABLE kd_parent (id int PRIMARY KEY);
+CREATE TABLE kd_child (id int, parent_id int REFERENCES kd_parent (id));
+CREATE TABLE kd_fanout_child
+(
+    id int,
+    parent_id int NOT NULL REFERENCES kd_parent (id)
+);
+CREATE TABLE kd_unique_child
+(
+    id int,
+    parent_id int UNIQUE NOT NULL REFERENCES kd_parent (id)
+);
+-- Missing: no foreign key matches the referencing columns (id is not an FK).
+SELECT * FROM kd_parent p JOIN kd_child c FOR KEY (id) -> p (id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 1: SELECT * FROM kd_parent p JOIN kd_child c FOR KEY (id) -> p ...
+                                                  ^
+DETAIL:  There is no matching foreign key constraint for c (id) referencing p (id).
+-- Inactivated: a derived referenced surface drops its row coverage at LIMIT.
+SELECT *
+FROM (SELECT id FROM kd_parent LIMIT 1) p
+JOIN kd_child c FOR KEY (parent_id) -> p (id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 3: JOIN kd_child c FOR KEY (parent_id) -> p (id);
+                        ^
+DETAIL:  Not every c (parent_id) value can be proven to have a matching p row. Referenced relation p may lose rows before this key join because of HAVING, LIMIT, OFFSET, FOR UPDATE or TABLESAMPLE.
+-- Inactivated by HAVING: the row-removing clause is named.
+SELECT *
+FROM (SELECT id FROM kd_parent GROUP BY id HAVING count(*) > 0) p
+JOIN kd_child c FOR KEY (parent_id) -> p (id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 3: JOIN kd_child c FOR KEY (parent_id) -> p (id);
+                        ^
+DETAIL:  Not every c (parent_id) value can be proven to have a matching p row. Referenced relation p may lose rows before this key join because of HAVING, LIMIT, OFFSET, FOR UPDATE or TABLESAMPLE.
+-- Inactivated uniqueness: the referenced surface may fan out.
+SELECT *
+FROM (
+    SELECT p.id
+    FROM kd_parent p
+    LEFT JOIN kd_fanout_child f FOR KEY (parent_id) -> p (id)
+) q
+JOIN kd_child c FOR KEY (parent_id) -> q (id);
+ERROR:  key join from referencing relation c to referenced relation q cannot be proven
+LINE 7: JOIN kd_child c FOR KEY (parent_id) -> q (id);
+                        ^
+DETAIL:  Referenced columns q (id) are not proven unique. A preceding join may duplicate rows from referenced relation q.
+-- Inactivated row coverage: the referenced surface may lose rows.
+SELECT *
+FROM (
+    SELECT p.id
+    FROM kd_parent p
+    JOIN kd_unique_child u FOR KEY (parent_id) -> p (id)
+) q
+JOIN kd_child c FOR KEY (parent_id) -> q (id);
+ERROR:  key join from referencing relation c to referenced relation q cannot be proven
+LINE 7: JOIN kd_child c FOR KEY (parent_id) -> q (id);
+                        ^
+DETAIL:  Not every c (parent_id) value can be proven to have a matching q row. Referenced relation q may lose rows before this key join because of a preceding join.
+-- Inactivated row coverage inside a view: name the view with the preceding join.
+CREATE VIEW kd_parent_joined AS
+SELECT p.id
+FROM kd_parent p
+JOIN kd_unique_child u FOR KEY (parent_id) -> p (id);
+SELECT *
+FROM kd_parent_joined p
+JOIN kd_child c FOR KEY (parent_id) -> p (id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 3: JOIN kd_child c FOR KEY (parent_id) -> p (id);
+                        ^
+DETAIL:  Not every c (parent_id) value can be proven to have a matching p row. Referenced relation p may lose rows before this key join because of a preceding join. The relevant operation occurs inside view key_join_diag.kd_parent_joined.
+DROP VIEW kd_parent_joined;
+-- Inactivated inside a view: the message names the view to inspect.
+CREATE VIEW kd_parent_limited AS SELECT id FROM kd_parent LIMIT 1;
+SELECT *
+FROM kd_parent_limited p
+JOIN kd_child c FOR KEY (parent_id) -> p (id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 3: JOIN kd_child c FOR KEY (parent_id) -> p (id);
+                        ^
+DETAIL:  Not every c (parent_id) value can be proven to have a matching p row. Referenced relation p may lose rows before this key join because of HAVING, LIMIT, OFFSET, FOR UPDATE or TABLESAMPLE. The relevant operation occurs inside view key_join_diag.kd_parent_limited.
+DROP VIEW kd_parent_limited;
+RESET search_path;
+DROP SCHEMA key_join_diag CASCADE;
+NOTICE:  drop cascades to 4 other objects
+DETAIL:  drop cascades to table key_join_diag.kd_parent
+drop cascades to table key_join_diag.kd_child
+drop cascades to table key_join_diag.kd_fanout_child
+drop cascades to table key_join_diag.kd_unique_child
+-- Final cleanup: drop all remaining objects
+DROP VIEW v1, v2;
+DROP TABLE shipments, orders, order_items, packages CASCADE;
+DROP TABLE customer_addresses CASCADE;
+DROP TABLE addresses, customers CASCADE;
+NOTICE:  drop cascades to view customer_details
+DROP TABLE t11, t10, t9, t8, t7, t6, t5, t4, t3, t2, t1 CASCADE;
diff --git a/src/test/regress/expected/key_join_icu.out b/src/test/regress/expected/key_join_icu.out
new file mode 100644
index 00000000000..74be603dea5
--- /dev/null
+++ b/src/test/regress/expected/key_join_icu.out
@@ -0,0 +1,105 @@
+--
+-- key_join_icu
+--
+-- FOR KEY join cases that depend on an ICU nondeterministic collation.  Such a
+-- collation can only be created when the database encoding is one that ICU
+-- supports (e.g. UTF8), so these cases used to live inline in key_join.sql and
+-- key_join_mcdc.sql and broke the regression suite on SQL_ASCII databases and
+-- on builds without ICU.  Move them here and skip the whole file unless an ICU
+-- collation can actually be built; the rejection behaviour they exercise is
+-- collation-mechanism specific and not otherwise reachable without ICU.
+--
+/* skip test if not UTF8 server encoding or no ICU collations installed */
+SELECT getdatabaseencoding() <> 'UTF8' OR
+       (SELECT count(*) FROM pg_collation WHERE collprovider = 'i' AND collname <> 'unicode') = 0
+       AS skip_test \gset
+\if :skip_test
+\quit
+\endif
+CREATE SCHEMA key_join_icu;
+SET search_path = key_join_icu, public;
+-- Query-level DISTINCT over a nondeterministic collation cannot prove key
+-- uniqueness (originally in key_join.sql).
+CREATE COLLATION query_unique_nondet (provider = icu, locale = 'und',
+    deterministic = false);
+CREATE TABLE query_unique_nondet_parent
+(
+    id text COLLATE query_unique_nondet
+);
+CREATE TABLE query_unique_nondet_child
+(
+    parent_id text COLLATE query_unique_nondet
+);
+SELECT *
+FROM (SELECT DISTINCT id FROM query_unique_nondet_parent) p
+-- rejected, reason: nondeterministic DISTINCT collation cannot prove uniqueness
+JOIN query_unique_nondet_child c FOR KEY (parent_id) -> p (id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 4: JOIN query_unique_nondet_child c FOR KEY (parent_id) -> p (i...
+                                         ^
+DETAIL:  There is no matching foreign key constraint for c (parent_id) referencing p (id).
+DROP TABLE query_unique_nondet_child, query_unique_nondet_parent;
+DROP COLLATION query_unique_nondet;
+-- A nondeterministic filter collation may disagree on equality, so a filtered
+-- referenced relation cannot be proven covered (originally in key_join.sql).
+CREATE TABLE filter_collation_parent
+(
+    code text COLLATE "C" PRIMARY KEY
+);
+CREATE TABLE filter_collation_child
+(
+    code text COLLATE "C" NOT NULL
+        REFERENCES filter_collation_parent (code)
+);
+INSERT INTO filter_collation_parent VALUES ('a'), ('b');
+INSERT INTO filter_collation_child VALUES ('a'), ('b');
+CREATE COLLATION filter_collation_nondet (provider = icu, locale = 'und',
+    deterministic = false);
+CREATE VIEW filter_collation_parent_nondet AS
+SELECT code FROM filter_collation_parent
+WHERE code = 'a'::text COLLATE filter_collation_nondet;
+CREATE VIEW filter_collation_child_nondet AS
+SELECT code FROM filter_collation_child
+WHERE code = 'a'::text COLLATE filter_collation_nondet;
+-- rejected, reason: nondeterministic filter collation may disagree on equality
+SELECT *
+FROM filter_collation_parent_nondet p
+JOIN filter_collation_child_nondet c FOR KEY (code) -> p (code);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 3: JOIN filter_collation_child_nondet c FOR KEY (code) -> p (co...
+                                             ^
+DETAIL:  Not every c (code) value can be proven to have a matching p row. Referenced relation p is filtered before this key join. The relevant operation occurs inside view key_join_icu.filter_collation_parent_nondet.
+DROP VIEW filter_collation_child_nondet, filter_collation_parent_nondet;
+DROP COLLATION filter_collation_nondet;
+DROP TABLE filter_collation_child, filter_collation_parent;
+-- GROUP BY over a nondeterministic-collation key projects no key facts, so the
+-- key join cannot be proven (originally in key_join_mcdc.sql, which runs with
+-- terse error verbosity).
+\set VERBOSITY terse
+CREATE COLLATION mcdc_nondet (provider = icu, locale = 'und',
+    deterministic = false);
+CREATE TABLE mcdc_text_parent
+(
+    id text COLLATE mcdc_nondet PRIMARY KEY
+);
+CREATE TABLE mcdc_text_reader
+(
+    id        int PRIMARY KEY,
+    parent_id text COLLATE mcdc_nondet NOT NULL
+        REFERENCES mcdc_text_parent (id)
+);
+INSERT INTO mcdc_text_parent VALUES ('a'), ('b');
+INSERT INTO mcdc_text_reader VALUES (301, 'a'), (302, 'b');
+SELECT tr.id, g.id
+FROM (
+    SELECT p.id
+    FROM mcdc_text_parent p
+    GROUP BY p.id
+) g
+JOIN mcdc_text_reader tr FOR KEY (parent_id) -> g (id)
+ORDER BY tr.id;
+ERROR:  key join from referencing relation tr to referenced relation g cannot be proven at character 118
+DROP TABLE mcdc_text_reader, mcdc_text_parent;
+DROP COLLATION mcdc_nondet;
+\set VERBOSITY default
+DROP SCHEMA key_join_icu;
diff --git a/src/test/regress/expected/key_join_icu_1.out b/src/test/regress/expected/key_join_icu_1.out
new file mode 100644
index 00000000000..62a29e8ca9f
--- /dev/null
+++ b/src/test/regress/expected/key_join_icu_1.out
@@ -0,0 +1,17 @@
+--
+-- key_join_icu
+--
+-- FOR KEY join cases that depend on an ICU nondeterministic collation.  Such a
+-- collation can only be created when the database encoding is one that ICU
+-- supports (e.g. UTF8), so these cases used to live inline in key_join.sql and
+-- key_join_mcdc.sql and broke the regression suite on SQL_ASCII databases and
+-- on builds without ICU.  Move them here and skip the whole file unless an ICU
+-- collation can actually be built; the rejection behaviour they exercise is
+-- collation-mechanism specific and not otherwise reachable without ICU.
+--
+/* skip test if not UTF8 server encoding or no ICU collations installed */
+SELECT getdatabaseencoding() <> 'UTF8' OR
+       (SELECT count(*) FROM pg_collation WHERE collprovider = 'i' AND collname <> 'unicode') = 0
+       AS skip_test \gset
+\if :skip_test
+\quit
diff --git a/src/test/regress/expected/key_join_matched_filter.out b/src/test/regress/expected/key_join_matched_filter.out
new file mode 100644
index 00000000000..2f762738699
--- /dev/null
+++ b/src/test/regress/expected/key_join_matched_filter.out
@@ -0,0 +1,1091 @@
+--
+-- Test the matched-filter rule for FOR KEY joins through derived tables.
+--
+-- A derived table whose WHERE filters its rows is "row-preserving modulo
+-- that filter".  A key join may use such a derived table on the
+-- referenced (PK) side when the referencing (FK) side applies a matching
+-- direct key equality filter on the corresponding key columns.  The value
+-- side must still match structurally after constant folding and FK-mapped Var
+-- substitution, and the filter must use the exact key identity for that FK
+-- position.
+--
+CREATE SCHEMA mf;
+SET search_path = mf, public;
+-- ===========================================================================
+-- Multi-tenant schema: composite FK, both sides parameterised by tenant_id.
+-- ===========================================================================
+CREATE TABLE customers (
+    tenant_id INTEGER NOT NULL,
+    id        INTEGER NOT NULL,
+    name      TEXT    NOT NULL,
+    active    BOOLEAN NOT NULL DEFAULT true,
+    PRIMARY KEY (tenant_id, id)
+);
+CREATE TABLE orders (
+    tenant_id   INTEGER NOT NULL,
+    id          INTEGER NOT NULL,
+    customer_id INTEGER NOT NULL,
+    amount      NUMERIC NOT NULL,
+    PRIMARY KEY (tenant_id, id),
+    FOREIGN KEY (tenant_id, customer_id) REFERENCES customers (tenant_id, id)
+);
+INSERT INTO customers VALUES
+    (1, 100, 'Alice',  true),
+    (1, 101, 'Bob',    false),
+    (2, 100, 'Carol',  true),
+    (2, 200, 'Dave',   true);
+INSERT INTO orders VALUES
+    (1, 1, 100, 9.99),
+    (1, 2, 101, 19.99),
+    (2, 1, 100,  4.50),
+    (2, 2, 200, 49.00);
+CREATE VIEW tenant_customers AS
+    SELECT * FROM customers
+    WHERE tenant_id = current_setting('mf.tenant')::int;
+CREATE VIEW tenant_orders AS
+    SELECT * FROM orders
+    WHERE tenant_id = current_setting('mf.tenant')::int;
+SET mf.tenant = '1';
+-- M1: Canonical multi-tenant case -- accepted
+SELECT o.id, c.name
+FROM tenant_orders o
+JOIN tenant_customers c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id)
+ORDER BY o.id;
+ id | name  
+----+-------
+  1 | Alice
+  2 | Bob
+(2 rows)
+
+-- M1b: Matched filters and FK columns may use non-first direct aliases -- accepted
+CREATE VIEW customer_aliases AS
+    SELECT tenant_id AS tenant_id_1, tenant_id AS tenant_id_2,
+           id AS id_1, id AS id_2, name
+    FROM customers;
+CREATE VIEW order_aliases AS
+    SELECT tenant_id AS tenant_id_1, tenant_id AS tenant_id_2,
+           id, customer_id AS customer_id_1, customer_id AS customer_id_2,
+           amount
+    FROM orders;
+CREATE VIEW tenant_customers_dup AS
+    SELECT * FROM customer_aliases
+    WHERE tenant_id_2 = current_setting('mf.tenant')::int;
+CREATE VIEW tenant_orders_dup AS
+    SELECT * FROM order_aliases
+    WHERE tenant_id_2 = current_setting('mf.tenant')::int;
+SELECT o.id, c.name
+FROM tenant_orders_dup o
+JOIN tenant_customers_dup c
+  FOR KEY (tenant_id_2, id_2) <- o (tenant_id_2, customer_id_2)
+ORDER BY o.id;
+ id | name  
+----+-------
+  1 | Alice
+  2 | Bob
+(2 rows)
+
+DROP VIEW tenant_orders_dup, tenant_customers_dup, order_aliases, customer_aliases;
+-- M1c: Same-domain key filters normalize through the base equality type -- accepted
+CREATE DOMAIN tenant_domain AS integer;
+CREATE TABLE domain_customers (
+    tenant_id tenant_domain NOT NULL,
+    id        INTEGER       NOT NULL,
+    name      TEXT          NOT NULL,
+    PRIMARY KEY (tenant_id, id)
+);
+CREATE TABLE domain_orders (
+    tenant_id   tenant_domain NOT NULL,
+    id          INTEGER       NOT NULL,
+    customer_id INTEGER       NOT NULL,
+    amount      NUMERIC       NOT NULL,
+    PRIMARY KEY (tenant_id, id),
+    FOREIGN KEY (tenant_id, customer_id)
+        REFERENCES domain_customers (tenant_id, id)
+);
+INSERT INTO domain_customers VALUES
+    (1, 100, 'Adele'),
+    (1, 101, 'Bert'),
+    (2, 100, 'Cleo');
+INSERT INTO domain_orders VALUES
+    (1, 1, 100, 8.25),
+    (1, 2, 101, 6.50),
+    (2, 1, 100, 4.75);
+CREATE VIEW domain_tenant_customers AS
+    SELECT * FROM domain_customers
+    WHERE tenant_id = 1;
+CREATE VIEW domain_tenant_orders AS
+    SELECT * FROM domain_orders
+    WHERE tenant_id = 1;
+SELECT o.id, c.name
+FROM domain_tenant_orders o
+JOIN domain_tenant_customers c
+  FOR KEY (tenant_id, id) <- o (tenant_id, customer_id)
+ORDER BY o.id;
+ id | name  
+----+-------
+  1 | Adele
+  2 | Bert
+(2 rows)
+
+DROP VIEW domain_tenant_orders, domain_tenant_customers;
+DROP TABLE domain_orders, domain_customers;
+DROP DOMAIN tenant_domain;
+-- Sanity: matches the explicit base-table form.
+SELECT o.id, c.name
+FROM orders o
+JOIN customers c
+  ON c.tenant_id = o.tenant_id AND c.id = o.customer_id
+WHERE o.tenant_id = current_setting('mf.tenant')::int
+  AND c.tenant_id = current_setting('mf.tenant')::int
+ORDER BY o.id;
+ id | name  
+----+-------
+  1 | Alice
+  2 | Bob
+(2 rows)
+
+-- M2: FK side missing the filter -- rejected, reason: referenced filter is not matched on the referencing side
+SELECT o.id, c.name
+FROM orders o
+JOIN tenant_customers c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+ERROR:  key join from referencing relation o to referenced relation c cannot be proven
+LINE 3: JOIN tenant_customers c FOR KEY (tenant_id, id) <- o (tenant...
+                                ^
+DETAIL:  Not every o (tenant_id, customer_id) value can be proven to have a matching c row. Referenced relation c has a filter that is not matched by referencing relation o.
+-- M2b: Join-local FILTER on the referencing key does not supply operand
+-- matched-filter evidence -- rejected.
+SELECT o.id, c.name
+FROM orders o
+JOIN tenant_customers c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id)
+FILTER (WHERE o.tenant_id = current_setting('mf.tenant')::int);
+ERROR:  key join from referencing relation o to referenced relation c cannot be proven
+LINE 3: JOIN tenant_customers c FOR KEY (tenant_id, id) <- o (tenant...
+                                ^
+DETAIL:  Not every o (tenant_id, customer_id) value can be proven to have a matching c row. Referenced relation c has a filter that is not matched by referencing relation o.
+-- M2c: Join-local FILTER on the referenced key also does not supply operand
+-- matched-filter evidence -- rejected.
+SELECT o.id, c.name
+FROM orders o
+JOIN tenant_customers c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id)
+FILTER (WHERE c.tenant_id = current_setting('mf.tenant')::int);
+ERROR:  key join from referencing relation o to referenced relation c cannot be proven
+LINE 3: JOIN tenant_customers c FOR KEY (tenant_id, id) <- o (tenant...
+                                ^
+DETAIL:  Not every o (tenant_id, customer_id) value can be proven to have a matching c row. Referenced relation c has a filter that is not matched by referencing relation o.
+-- M3: Different filter expressions (different constants) -- rejected, reason: referenced and referencing filters are not equivalent
+CREATE VIEW tenant_customers_2 AS
+    SELECT * FROM customers WHERE tenant_id = 2;
+CREATE VIEW tenant_orders_1 AS
+    SELECT * FROM orders WHERE tenant_id = 1;
+SELECT o.id, c.name
+FROM tenant_orders_1 o
+-- rejected, reason: referenced and referencing filters use different constants
+JOIN tenant_customers_2 c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+ERROR:  key join from referencing relation o to referenced relation c cannot be proven
+LINE 4: JOIN tenant_customers_2 c FOR KEY (tenant_id, id) <- o (tena...
+                                  ^
+DETAIL:  Not every o (tenant_id, customer_id) value can be proven to have a matching c row. Referenced relation c has a filter that is not matched by referencing relation o.
+DROP VIEW tenant_customers_2, tenant_orders_1;
+-- M4: Composite filter on both sides matching FK composite columns -- accepted
+CREATE VIEW c_pinpoint AS
+    SELECT * FROM customers WHERE tenant_id = 1 AND id = 100;
+CREATE VIEW o_pinpoint AS
+    SELECT * FROM orders    WHERE tenant_id = 1 AND customer_id = 100;
+SELECT o.id, c.name
+FROM o_pinpoint o
+-- accepted
+JOIN c_pinpoint c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id)
+ORDER BY o.id;
+ id | name  
+----+-------
+  1 | Alice
+(1 row)
+
+DROP VIEW c_pinpoint, o_pinpoint;
+-- M5: Volatile filter -- rejected, reason: PK side filter contains random().
+CREATE VIEW c_volatile AS
+    SELECT * FROM customers WHERE tenant_id = (random()*100)::int;
+CREATE VIEW o_volatile AS
+    SELECT * FROM orders    WHERE tenant_id = (random()*100)::int;
+SELECT o.id
+FROM o_volatile o
+-- rejected, reason: referenced-side filter contains volatile random()
+JOIN c_volatile c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+ERROR:  key join from referencing relation o to referenced relation c cannot be proven
+LINE 4: JOIN c_volatile c FOR KEY (tenant_id, id) <- o (tenant_id, c...
+                          ^
+DETAIL:  There is no matching foreign key constraint for o (tenant_id, customer_id) referencing c (tenant_id, id).
+DROP VIEW c_volatile, o_volatile;
+-- M6: Filter on a non-key column on the PK side -- rejected, reason: active is not part of the FK mapping
+-- The PK conjunct on `active` references a column that isn't part of the
+-- FK, so the conjunct cannot be carried across the FK mapping at all.
+CREATE VIEW active_customers AS
+    SELECT * FROM customers WHERE active;
+CREATE VIEW any_orders AS
+    SELECT * FROM orders WHERE amount > 0;
+SELECT o.id, c.name
+FROM any_orders o
+-- rejected, reason: referenced-side filter is on a non-key column
+JOIN active_customers c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+ERROR:  key join from referencing relation o to referenced relation c cannot be proven
+LINE 4: JOIN active_customers c FOR KEY (tenant_id, id) <- o (tenant...
+                                ^
+DETAIL:  Not every o (tenant_id, customer_id) value can be proven to have a matching c row. Referenced relation c is filtered before this key join. The relevant operation occurs inside view mf.active_customers.
+DROP VIEW active_customers, any_orders;
+-- M6b: OR on the referenced side -- rejected, reason: OR is not a safe
+-- key-filter conjunct for referenced-side rowCoverage.
+CREATE VIEW c_or_referenced AS
+    SELECT * FROM customers
+    WHERE tenant_id = current_setting('mf.tenant')::int OR active;
+SELECT o.id, c.name
+FROM tenant_orders o
+-- rejected, reason: referenced-side OR filter drops rowCoverage
+JOIN c_or_referenced c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+ERROR:  key join from referencing relation o to referenced relation c cannot be proven
+LINE 4: JOIN c_or_referenced c FOR KEY (tenant_id, id) <- o (tenant_...
+                               ^
+DETAIL:  Not every o (tenant_id, customer_id) value can be proven to have a matching c row. Referenced relation c is filtered before this key join. The relevant operation occurs inside view mf.c_or_referenced.
+DROP VIEW c_or_referenced;
+-- M6c: OR on the referencing side -- rejected, reason: the referencing
+-- FK fact must not retain tenant_id evidence from inside an OR.
+CREATE VIEW o_or_referencing AS
+    SELECT * FROM orders
+    WHERE tenant_id = current_setting('mf.tenant')::int OR amount = 2;
+SELECT o.id, c.name
+FROM o_or_referencing o
+-- rejected, reason: referencing-side OR filter does not match referenced filter
+JOIN tenant_customers c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+ERROR:  key join from referencing relation o to referenced relation c cannot be proven
+LINE 4: JOIN tenant_customers c FOR KEY (tenant_id, id) <- o (tenant...
+                                ^
+DETAIL:  Not every o (tenant_id, customer_id) value can be proven to have a matching c row. Referenced relation c has a filter that is not matched by referencing relation o.
+DROP VIEW o_or_referencing;
+-- M6d: Matching-looking OR on both sides -- rejected, reason: OR is not a
+-- safe matched-filter conjunct even when the same shape appears on both sides.
+CREATE VIEW c_or_both AS
+    SELECT * FROM customers
+    WHERE tenant_id = current_setting('mf.tenant')::int OR id = 100;
+CREATE VIEW o_or_both AS
+    SELECT * FROM orders
+    WHERE tenant_id = current_setting('mf.tenant')::int OR customer_id = 100;
+SELECT o.id, c.name
+FROM o_or_both o
+-- rejected, reason: OR filters are not retained as matched-filter evidence
+JOIN c_or_both c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+ERROR:  key join from referencing relation o to referenced relation c cannot be proven
+LINE 4: JOIN c_or_both c FOR KEY (tenant_id, id) <- o (tenant_id, cu...
+                         ^
+DETAIL:  Not every o (tenant_id, customer_id) value can be proven to have a matching c row. Referenced relation c is filtered before this key join. The relevant operation occurs inside view mf.c_or_both.
+DROP VIEW o_or_both, c_or_both;
+-- M6e: A disjunction nested under a top-level AND is still not a
+-- conjunct the proof system can retain, even when both sides have the
+-- same mapped Boolean shape.
+CREATE VIEW c_or_nested AS
+    SELECT * FROM customers
+    WHERE tenant_id = current_setting('mf.tenant')::int
+      AND (id = 100 OR tenant_id = 2);
+CREATE VIEW o_or_nested AS
+    SELECT * FROM orders
+    WHERE tenant_id = current_setting('mf.tenant')::int
+      AND (customer_id = 100 OR tenant_id = 2);
+SELECT o.id, c.name
+FROM o_or_nested o
+-- rejected, reason: nested Boolean filters are not retained as matched-filter evidence
+JOIN c_or_nested c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+ERROR:  key join from referencing relation o to referenced relation c cannot be proven
+LINE 4: JOIN c_or_nested c FOR KEY (tenant_id, id) <- o (tenant_id, ...
+                           ^
+DETAIL:  Not every o (tenant_id, customer_id) value can be proven to have a matching c row. Referenced relation c is filtered before this key join. The relevant operation occurs inside view mf.c_or_nested.
+DROP VIEW o_or_nested, c_or_nested;
+-- M7: Two-layer view, filter in the inner layer -- accepted
+CREATE VIEW c_inner AS
+    SELECT * FROM customers
+    WHERE tenant_id = current_setting('mf.tenant')::int;
+CREATE VIEW c_outer AS
+    SELECT * FROM c_inner;
+CREATE VIEW o_inner AS
+    SELECT * FROM orders
+    WHERE tenant_id = current_setting('mf.tenant')::int;
+CREATE VIEW o_outer AS
+    SELECT * FROM o_inner;
+SELECT o.id, c.name
+FROM o_outer o
+-- accepted
+JOIN c_outer c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id)
+ORDER BY o.id;
+ id | name  
+----+-------
+  1 | Alice
+  2 | Bob
+(2 rows)
+
+DROP VIEW c_outer, c_inner, o_outer, o_inner;
+-- M8: CTE on each side -- accepted
+WITH
+    co AS (SELECT * FROM customers WHERE tenant_id = current_setting('mf.tenant')::int),
+    oo AS (SELECT * FROM orders    WHERE tenant_id = current_setting('mf.tenant')::int)
+SELECT o.id, c.name
+FROM oo o
+JOIN co c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id)
+ORDER BY o.id;
+ id | name  
+----+-------
+  1 | Alice
+  2 | Bob
+(2 rows)
+
+-- M9: Matched filter on PK side, FK side has the matched filter PLUS an extra
+-- conjunct that doesn't reference FK columns -- accepted (PK-driven matching).
+CREATE VIEW big_orders AS
+    SELECT * FROM orders
+    WHERE tenant_id = current_setting('mf.tenant')::int
+      AND amount > 5;
+SELECT o.id, c.name
+FROM big_orders o
+-- accepted
+JOIN tenant_customers c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id)
+ORDER BY o.id;
+ id | name  
+----+-------
+  1 | Alice
+  2 | Bob
+(2 rows)
+
+DROP VIEW big_orders;
+-- M10: Matched filter on PK side has an extra conjunct that the FK side does
+-- not -- rejected, reason: referenced-side active conjunct is not matched (strict p_PK = p_match: every referenced-side conjunct
+-- must match).
+CREATE VIEW strict_customers AS
+    SELECT * FROM customers
+    WHERE tenant_id = current_setting('mf.tenant')::int
+      AND active;
+SELECT o.id, c.name
+FROM tenant_orders o
+-- rejected, reason: referenced side has an unmatched active filter
+JOIN strict_customers c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+ERROR:  key join from referencing relation o to referenced relation c cannot be proven
+LINE 4: JOIN strict_customers c FOR KEY (tenant_id, id) <- o (tenant...
+                                ^
+DETAIL:  Not every o (tenant_id, customer_id) value can be proven to have a matching c row. Referenced relation c is filtered before this key join. The relevant operation occurs inside view mf.strict_customers.
+DROP VIEW strict_customers;
+-- M11: System column on referenced side -- rejected, reason: system-column filters are not matched key filters (would have
+-- crashed before the varattno > 0 guard, since bms_is_member errors on
+-- negative members).
+CREATE VIEW c_sys AS
+    SELECT * FROM customers WHERE ctid IS NOT NULL;
+CREATE VIEW o_sys AS
+    SELECT * FROM orders    WHERE ctid IS NOT NULL;
+SELECT o.id
+FROM o_sys o
+-- rejected, reason: referenced-side filter uses a system column
+JOIN c_sys c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+ERROR:  key join from referencing relation o to referenced relation c cannot be proven
+LINE 4: JOIN c_sys c FOR KEY (tenant_id, id) <- o (tenant_id, custom...
+                     ^
+DETAIL:  Not every o (tenant_id, customer_id) value can be proven to have a matching c row. Referenced relation c is filtered before this key join. The relevant operation occurs inside view mf.c_sys.
+DROP VIEW c_sys, o_sys;
+-- M12: Parameterised filters are not retained as matched-filter evidence.
+PREPARE mf_p AS
+    SELECT o.id, c.name
+    FROM (SELECT * FROM orders    WHERE tenant_id = $1) o
+    JOIN (SELECT * FROM customers WHERE tenant_id = $1) c
+-- rejected, reason: parameterized filters are not matched-filter evidence
+        FOR KEY (tenant_id, id) <- o (tenant_id, customer_id)
+    ORDER BY o.id;
+ERROR:  key join from referencing relation o to referenced relation c cannot be proven
+LINE 6:         FOR KEY (tenant_id, id) <- o (tenant_id, customer_id...
+                ^
+DETAIL:  Not every o (tenant_id, customer_id) value can be proven to have a matching c row. Referenced relation c is filtered before this key join.
+-- M12b: Asymmetric parameterised filter — referenced side has $1, the
+-- referencing side has a constant value → rejected for the same reason.
+PREPARE mf_p_bad AS
+    SELECT o.id, c.name
+    FROM (SELECT * FROM orders    WHERE tenant_id = 1) o
+    JOIN (SELECT * FROM customers WHERE tenant_id = $1) c
+-- rejected, reason: referenced-side parameterized filter is not proof evidence
+        FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+ERROR:  key join from referencing relation o to referenced relation c cannot be proven
+LINE 6:         FOR KEY (tenant_id, id) <- o (tenant_id, customer_id...
+                ^
+DETAIL:  Not every o (tenant_id, customer_id) value can be proven to have a matching c row. Referenced relation c is filtered before this key join.
+-- M12c: Proof placeholders must not collide with real SQL parameters.
+-- The referenced-side $1 used to be remapped as if it were the tenant_id
+-- key placeholder, making customer_id = tenant_id look like it matched
+-- id = $1.
+CREATE TABLE param_customers (
+    tenant_id INTEGER NOT NULL,
+    id        INTEGER NOT NULL,
+    name      TEXT    NOT NULL,
+    PRIMARY KEY (tenant_id, id)
+);
+CREATE TABLE param_orders (
+    tenant_id   INTEGER NOT NULL,
+    id          INTEGER NOT NULL,
+    customer_id INTEGER NOT NULL,
+    PRIMARY KEY (tenant_id, id),
+    FOREIGN KEY (tenant_id, customer_id) REFERENCES param_customers (tenant_id, id)
+);
+INSERT INTO param_customers VALUES (1, 1, 'One'), (1, 2, 'Two');
+INSERT INTO param_orders VALUES (1, 1, 1);
+PREPARE mf_p_collision(int, int) AS
+    SELECT o.id, c.name
+    FROM (SELECT * FROM param_orders WHERE customer_id = tenant_id) o
+    JOIN (SELECT * FROM param_customers WHERE id = $1) c
+-- rejected, reason: referenced-side SQL parameter is not proof evidence
+        FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+ERROR:  key join from referencing relation o to referenced relation c cannot be proven
+LINE 6:         FOR KEY (tenant_id, id) <- o (tenant_id, customer_id...
+                ^
+DETAIL:  Not every o (tenant_id, customer_id) value can be proven to have a matching c row. Referenced relation c is filtered before this key join.
+DROP TABLE param_orders, param_customers;
+-- M13: current_user inside a STABLE-driven filter on a column that IS
+-- in the FK -- accepted. Adds a tenant_role column to both sides that
+-- gets its value from the user, with a tenant_id-shaped FK so the
+-- matched filter is on a column the FK column mapping covers.
+DROP VIEW tenant_orders, tenant_customers;
+ALTER TABLE customers ADD COLUMN owner_role NAME NOT NULL DEFAULT current_user;
+ALTER TABLE orders    ADD COLUMN owner_role NAME NOT NULL DEFAULT current_user;
+-- Extend the existing FK so owner_role is one of the matched columns.
+ALTER TABLE orders    DROP CONSTRAINT orders_tenant_id_customer_id_fkey;
+ALTER TABLE customers DROP CONSTRAINT customers_pkey;
+ALTER TABLE customers ADD CONSTRAINT customers_pkey
+    PRIMARY KEY (tenant_id, owner_role, id);
+ALTER TABLE orders    ADD FOREIGN KEY (tenant_id, owner_role, customer_id)
+    REFERENCES customers (tenant_id, owner_role, id);
+CREATE VIEW c_mine AS
+    SELECT * FROM customers WHERE owner_role = current_user;
+CREATE VIEW o_mine AS
+    SELECT * FROM orders    WHERE owner_role = current_user;
+SELECT o.id, c.name
+FROM o_mine o
+-- accepted
+JOIN c_mine c FOR KEY (tenant_id, owner_role, id)
+              <- o (tenant_id, owner_role, customer_id)
+ORDER BY o.id, c.tenant_id;
+ id | name  
+----+-------
+  1 | Alice
+  1 | Carol
+  2 | Bob
+  2 | Dave
+(4 rows)
+
+DROP VIEW c_mine, o_mine;
+-- Restore the original schema for subsequent cases.
+ALTER TABLE orders    DROP CONSTRAINT orders_tenant_id_owner_role_customer_id_fkey;
+ALTER TABLE customers DROP CONSTRAINT customers_pkey;
+ALTER TABLE customers ADD CONSTRAINT customers_pkey PRIMARY KEY (tenant_id, id);
+ALTER TABLE orders    ADD FOREIGN KEY (tenant_id, customer_id)
+    REFERENCES customers (tenant_id, id);
+ALTER TABLE customers DROP COLUMN owner_role;
+ALTER TABLE orders    DROP COLUMN owner_role;
+CREATE VIEW tenant_customers AS
+    SELECT * FROM customers WHERE tenant_id = current_setting('mf.tenant')::int;
+CREATE VIEW tenant_orders AS
+    SELECT * FROM orders    WHERE tenant_id = current_setting('mf.tenant')::int;
+-- M14: Inequality filter on both sides -- rejected, reason: matched filters
+-- must be direct equality predicates.
+-- Arbitrary inequalities are not accepted just because their expression
+-- trees match.
+CREATE VIEW c_ineq AS SELECT * FROM customers WHERE tenant_id < 10;
+CREATE VIEW o_ineq AS SELECT * FROM orders    WHERE tenant_id < 10;
+SELECT o.id, c.name
+FROM o_ineq o
+-- rejected, reason: matched filters must be direct equality predicates
+JOIN c_ineq c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id)
+ORDER BY o.id;
+ERROR:  key join from referencing relation o to referenced relation c cannot be proven
+LINE 4: JOIN c_ineq c FOR KEY (tenant_id, id) <- o (tenant_id, custo...
+                      ^
+DETAIL:  Not every o (tenant_id, customer_id) value can be proven to have a matching c row. Referenced relation c is filtered before this key join. The relevant operation occurs inside view mf.c_ineq.
+DROP VIEW c_ineq, o_ineq;
+-- M15: Nested key join through conditionally row-preserved derived tables.
+-- The inner key join (payments → orders) accepts under the
+-- matched-filter rule.  The outer key join (orders → customers)
+-- should also accept by propagating the matched row-coverage chain
+-- from payments through orders to customers.
+CREATE TABLE payments (
+    tenant_id INTEGER NOT NULL,
+    id        INTEGER NOT NULL,
+    order_id  INTEGER NOT NULL,
+    amount    NUMERIC NOT NULL,
+    PRIMARY KEY (tenant_id, id),
+    FOREIGN KEY (tenant_id, order_id) REFERENCES orders (tenant_id, id)
+);
+INSERT INTO payments VALUES
+    (1, 9001, 1, 9.99),
+    (1, 9002, 2, 19.99),
+    (2, 9001, 1, 4.50);
+CREATE VIEW tenant_payments AS
+    SELECT * FROM payments
+    WHERE tenant_id = current_setting('mf.tenant')::int;
+SELECT o.id AS order_id, c.name AS customer_name, p.amount
+FROM tenant_payments p
+JOIN tenant_orders o
+-- accepted
+    FOR KEY (tenant_id, id) <- p (tenant_id, order_id)
+JOIN tenant_customers c
+-- accepted
+    FOR KEY (tenant_id, id) <- o (tenant_id, customer_id)
+ORDER BY p.id;
+ order_id | customer_name | amount 
+----------+---------------+--------
+        1 | Alice         |   9.99
+        2 | Bob           |  19.99
+(2 rows)
+
+-- M15b: The middle orders input is unfiltered.  The first key join
+-- proves p.tenant_id = o.tenant_id for rows in the result, so the
+-- tenant_payments filter is materialized onto the output orders FK fact
+-- and can satisfy tenant_customers in the second key join.
+SELECT o.id AS order_id, c.name AS customer_name, p.amount
+FROM tenant_payments p
+JOIN orders o
+-- accepted
+    FOR KEY (tenant_id, id) <- p (tenant_id, order_id)
+JOIN tenant_customers c
+-- accepted
+    FOR KEY (tenant_id, id) <- o (tenant_id, customer_id)
+ORDER BY p.id;
+ order_id | customer_name | amount 
+----------+---------------+--------
+        1 | Alice         |   9.99
+        2 | Bob           |  19.99
+(2 rows)
+
+DROP VIEW tenant_payments;
+DROP TABLE payments;
+-- M16: Multi-layer view, filter only at the OUTER layer -- accepted
+-- The outer WHERE references the inner view's RTE; absorption translates
+-- it through the inner view's targetlist (a SELECT * passthrough) into
+-- the leaf base table's frame.
+CREATE VIEW c_passthrough AS SELECT * FROM customers;
+CREATE VIEW c_outer_filter AS
+    SELECT * FROM c_passthrough WHERE tenant_id = 1;
+CREATE VIEW o_t1 AS
+    SELECT * FROM orders WHERE tenant_id = 1;
+SELECT o.id, c.name
+FROM o_t1 o
+-- accepted
+JOIN c_outer_filter c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id)
+ORDER BY o.id, c.id;
+ id | name  
+----+-------
+  1 | Alice
+  2 | Bob
+(2 rows)
+
+DROP VIEW o_t1, c_outer_filter, c_passthrough;
+-- M17: Multi-layer view with filters at BOTH layers -- accepted when
+-- the referencing side applies the conjunction of both filters.
+CREATE VIEW c_inner_tenant AS
+    SELECT * FROM customers WHERE tenant_id = 1;
+CREATE VIEW c_outer_id AS
+    SELECT * FROM c_inner_tenant WHERE id = 100;
+CREATE VIEW o_combined AS
+    SELECT * FROM orders WHERE tenant_id = 1 AND customer_id = 100;
+SELECT o.id, c.name
+FROM o_combined o
+-- accepted
+JOIN c_outer_id c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+ id | name  
+----+-------
+  1 | Alice
+(1 row)
+
+DROP VIEW o_combined;
+-- M18: Multi-layer view with filters at BOTH layers, but the referencing
+-- side filters the FK column to a different value -- rejected, reason: referencing-side filter maps to a different key value.
+-- Both the inner WHERE (tenant_id = 1) and outer WHERE (id = 100) must be
+-- captured as matched-filter evidence; otherwise the join would accept while
+-- silently dropping rows at runtime.
+CREATE VIEW o_mismatch AS
+    SELECT * FROM orders WHERE tenant_id = 1 AND customer_id = 999;
+SELECT o.id, c.name
+FROM o_mismatch o
+-- rejected, reason: referencing side filters the FK column to a different value
+JOIN c_outer_id c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+ERROR:  key join from referencing relation o to referenced relation c cannot be proven
+LINE 4: JOIN c_outer_id c FOR KEY (tenant_id, id) <- o (tenant_id, c...
+                          ^
+DETAIL:  Not every o (tenant_id, customer_id) value can be proven to have a matching c row. Referenced relation c has a filter that is not matched by referencing relation o.
+DROP VIEW o_mismatch, c_outer_id, c_inner_tenant;
+-- M19: Multi-layer view with LIMIT at the outer layer — hard disqualifier
+-- at the absorbing layer, drops referenced-side rowCoverage entirely.
+CREATE VIEW c_inner_for_lim AS
+    SELECT * FROM customers WHERE tenant_id = 1;
+CREATE VIEW c_outer_lim AS
+    SELECT * FROM c_inner_for_lim LIMIT 5;
+SELECT o.id, c.name
+FROM tenant_orders o
+-- rejected, reason: referenced-side outer view has LIMIT
+JOIN c_outer_lim c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+ERROR:  key join from referencing relation o to referenced relation c cannot be proven
+LINE 4: JOIN c_outer_lim c FOR KEY (tenant_id, id) <- o (tenant_id, ...
+                           ^
+DETAIL:  Not every o (tenant_id, customer_id) value can be proven to have a matching c row. Referenced relation c may lose rows before this key join because of HAVING, LIMIT, OFFSET, FOR UPDATE or TABLESAMPLE. The relevant operation occurs inside view mf.c_outer_lim.
+DROP VIEW c_outer_lim, c_inner_for_lim;
+-- M20: Multi-layer view with a SubLink in the outer WHERE — the conjunct
+-- cannot be represented as a safe key conjunct after Var translation, so the
+-- referenced-side rowCoverage fact is dropped.
+CREATE VIEW c_inner_for_sub AS
+    SELECT * FROM customers WHERE tenant_id = 1;
+CREATE VIEW c_outer_sub AS
+    SELECT * FROM c_inner_for_sub WHERE EXISTS (SELECT 1);
+SELECT o.id, c.name
+FROM tenant_orders o
+-- rejected, reason: referenced-side outer WHERE contains an unverifiable sublink
+JOIN c_outer_sub c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+ERROR:  key join from referencing relation o to referenced relation c cannot be proven
+LINE 4: JOIN c_outer_sub c FOR KEY (tenant_id, id) <- o (tenant_id, ...
+                           ^
+DETAIL:  Not every o (tenant_id, customer_id) value can be proven to have a matching c row. Referenced relation c is filtered before this key join. The relevant operation occurs inside view mf.c_outer_sub.
+DROP VIEW c_outer_sub, c_inner_for_sub;
+-- M21: NextValueExpr (volatile, sequence-accessing) in a referenced-side
+-- WHERE.  The capture-time contain_volatile_functions check flags this and
+-- the entry is rejected as "filters this rule cannot verify".  Without that
+-- check, two views carrying the structurally-identical nextval expression
+-- would equal()-match in try_matched_filter and be unsoundly accepted, even
+-- though their runtime evaluations would draw different sequence values.
+CREATE SEQUENCE mf_seq;
+CREATE VIEW c_nextval AS
+    SELECT * FROM customers WHERE id = nextval('mf_seq')::int;
+CREATE VIEW o_nextval AS
+    SELECT * FROM orders    WHERE customer_id = nextval('mf_seq')::int;
+SELECT o.id
+FROM o_nextval o
+-- rejected, reason: referenced-side filter contains volatile nextval()
+JOIN c_nextval c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+ERROR:  key join from referencing relation o to referenced relation c cannot be proven
+LINE 4: JOIN c_nextval c FOR KEY (tenant_id, id) <- o (tenant_id, cu...
+                         ^
+DETAIL:  There is no matching foreign key constraint for o (tenant_id, customer_id) referencing c (tenant_id, id).
+DROP VIEW c_nextval, o_nextval;
+DROP SEQUENCE mf_seq;
+-- M21b: User-defined same-name equality operators are not FK equality
+-- evidence -- rejected, reason: matched-filter evidence must use the FK
+-- position's common catalog equality operator.
+CREATE FUNCTION mf_op_eq(integer, integer) RETURNS boolean
+    LANGUAGE sql STABLE AS $$ SELECT $1 OPERATOR(pg_catalog.=) $2 $$;
+CREATE OPERATOR = (
+    LEFTARG = integer,
+    RIGHTARG = integer,
+    FUNCTION = mf_op_eq
+);
+CREATE VIEW op_customers AS
+    SELECT * FROM customers WHERE tenant_id OPERATOR(mf.=) 1;
+CREATE VIEW op_orders AS
+    SELECT * FROM orders WHERE tenant_id OPERATOR(mf.=) 1;
+SELECT o.id, c.name
+FROM op_orders o
+-- rejected, reason: mf.= is not the FK equality operator
+JOIN op_customers c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+ERROR:  key join from referencing relation o to referenced relation c cannot be proven
+LINE 4: JOIN op_customers c FOR KEY (tenant_id, id) <- o (tenant_id,...
+                            ^
+DETAIL:  Not every o (tenant_id, customer_id) value can be proven to have a matching c row. Referenced relation c is filtered before this key join. The relevant operation occurs inside view mf.op_customers.
+-- M21c: Propagated user-defined equality evidence is still rejected at the
+-- consuming key join.
+CREATE TABLE op_payments (
+    tenant_id integer NOT NULL,
+    id        integer NOT NULL,
+    order_id  integer NOT NULL,
+    PRIMARY KEY (tenant_id, id),
+    FOREIGN KEY (tenant_id, order_id) REFERENCES orders (tenant_id, id)
+);
+INSERT INTO op_payments VALUES (1, 1, 1), (1, 2, 2), (2, 1, 1);
+CREATE VIEW op_payments_v AS
+    SELECT * FROM op_payments WHERE tenant_id OPERATOR(mf.=) 1;
+SELECT p.id AS payment_id, o.id AS order_id, c.name
+FROM op_payments_v p
+JOIN orders o FOR KEY (tenant_id, id) <- p (tenant_id, order_id)
+-- rejected, reason: propagated mf.= evidence is not FK equality evidence
+JOIN op_customers c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+ERROR:  key join from referencing relation o to referenced relation c cannot be proven
+LINE 5: JOIN op_customers c FOR KEY (tenant_id, id) <- o (tenant_id,...
+                            ^
+DETAIL:  Not every o (tenant_id, customer_id) value can be proven to have a matching c row. Referenced relation c is filtered before this key join. The relevant operation occurs inside view mf.op_customers.
+DROP VIEW op_payments_v, op_orders, op_customers;
+DROP TABLE op_payments;
+DROP OPERATOR mf.= (integer, integer);
+DROP FUNCTION mf_op_eq(integer, integer);
+-- M22: View body with a non-key JOIN -- caught by the absence of exported
+-- rowCoverage for arbitrary ordinary joins.  Only accepted key joins and the
+-- single-row companion cross-join case export facts to later key joins.
+CREATE VIEW c_join AS
+    SELECT a.tenant_id, a.id, a.name, a.active, b.amount
+    FROM customers a
+    JOIN orders b ON a.tenant_id = b.tenant_id AND a.id = b.customer_id
+    WHERE a.tenant_id = 1;
+SELECT o.id, c.name
+FROM tenant_orders o
+-- rejected, reason: referenced view body contains a non-key join
+JOIN c_join c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+ERROR:  key join from referencing relation o to referenced relation c cannot be proven
+LINE 4: JOIN c_join c FOR KEY (tenant_id, id) <- o (tenant_id, custo...
+                      ^
+DETAIL:  There is no matching foreign key constraint for o (tenant_id, customer_id) referencing c (tenant_id, id).
+DROP VIEW c_join;
+-- M23: View with set-returning function in target list.  hasTargetSRFs is
+-- caught by drill_down_to_base_rel_query at FK validation; this exercises
+-- that the existing rejection still fires after the soundness changes.
+CREATE VIEW c_srf AS
+    SELECT generate_series(1, 5) AS gs, c.* FROM customers c WHERE tenant_id = 1;
+SELECT o.id, c.name
+FROM tenant_orders o
+-- rejected, reason: referenced view target list contains a set-returning function
+JOIN c_srf c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+ERROR:  key join from referencing relation o to referenced relation c cannot be proven
+LINE 4: JOIN c_srf c FOR KEY (tenant_id, id) <- o (tenant_id, custom...
+                     ^
+DETAIL:  There is no matching foreign key constraint for o (tenant_id, customer_id) referencing c (tenant_id, id).
+DROP VIEW c_srf;
+-- M24: Direct equality predicate forms -- accepted.
+CREATE VIEW c_types AS
+    SELECT * FROM customers
+    WHERE tenant_id = 1;
+CREATE VIEW o_types AS
+    SELECT * FROM orders
+    WHERE tenant_id = 1;
+SELECT o.id, c.name
+FROM o_types o
+-- accepted
+JOIN c_types c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id)
+ORDER BY o.id;
+ id | name  
+----+-------
+  1 | Alice
+  2 | Bob
+(2 rows)
+
+DROP VIEW o_types, c_types;
+-- M24b: Richer predicate forms are no longer retained as proof evidence --
+-- rejected, reason: matched-filter evidence is direct key equality only.
+CREATE VIEW c_types AS
+    SELECT * FROM customers
+    WHERE tenant_id = 1
+      AND tenant_id <> 99
+      AND tenant_id IN (1, 2)
+      AND tenant_id NOT IN (99, 100)
+      AND tenant_id IS NOT NULL
+      AND NOT (tenant_id = 99)
+      AND tenant_id IS DISTINCT FROM 99
+      AND (tenant_id = 1) IS TRUE;
+CREATE VIEW o_types AS
+    SELECT * FROM orders
+    WHERE tenant_id = 1
+      AND tenant_id <> 99
+      AND tenant_id IN (1, 2)
+      AND tenant_id NOT IN (99, 100)
+      AND tenant_id IS NOT NULL
+      AND NOT (tenant_id = 99)
+      AND tenant_id IS DISTINCT FROM 99
+      AND (tenant_id = 1) IS TRUE;
+SELECT o.id, c.name
+FROM o_types o
+-- rejected, reason: richer predicates are not direct equality evidence
+JOIN c_types c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id)
+ORDER BY o.id;
+ERROR:  key join from referencing relation o to referenced relation c cannot be proven
+LINE 4: JOIN c_types c FOR KEY (tenant_id, id) <- o (tenant_id, cust...
+                       ^
+DETAIL:  Not every o (tenant_id, customer_id) value can be proven to have a matching c row. Referenced relation c is filtered before this key join. The relevant operation occurs inside view mf.c_types.
+DROP VIEW o_types, c_types;
+-- M25: Key-column transformations -- rejected, reason: immutable functions can
+-- distinguish values that the FK equality operator treats as equal, so a
+-- structurally matching transformed-key predicate is not a safe matched filter.
+CREATE VIEW c_transformed AS
+    SELECT * FROM customers
+    WHERE abs(tenant_id) = 1;
+CREATE VIEW o_transformed AS
+    SELECT * FROM orders
+    WHERE abs(tenant_id) = 1;
+SELECT o.id
+FROM o_transformed o
+-- rejected, reason: matched filter transforms the FK column
+JOIN c_transformed c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+ERROR:  key join from referencing relation o to referenced relation c cannot be proven
+LINE 4: JOIN c_transformed c FOR KEY (tenant_id, id) <- o (tenant_id...
+                             ^
+DETAIL:  Not every o (tenant_id, customer_id) value can be proven to have a matching c row. Referenced relation c is filtered before this key join. The relevant operation occurs inside view mf.c_transformed.
+DROP VIEW o_transformed, c_transformed;
+-- M26: Numeric transformed-key counterexample -- rejected, reason: PostgreSQL
+-- numeric equality treats 1.0 and 1.00 as equal, but scale(numeric) sees
+-- different display scales; accepting this matched filter would drop the
+-- preserved child row.
+CREATE TABLE numeric_parent (
+    id numeric PRIMARY KEY
+);
+CREATE TABLE numeric_child (
+    id        integer PRIMARY KEY,
+    parent_id numeric NOT NULL REFERENCES numeric_parent (id)
+);
+INSERT INTO numeric_parent VALUES (1.0);
+INSERT INTO numeric_child VALUES (1, 1.00);
+CREATE VIEW numeric_parent_scale2 AS
+    SELECT * FROM numeric_parent WHERE scale(id) = 2;
+CREATE VIEW numeric_child_scale2 AS
+    SELECT * FROM numeric_child WHERE scale(parent_id) = 2;
+SELECT c.id, p.id
+FROM numeric_child_scale2 c
+-- rejected, reason: scale() can distinguish values considered equal by FK equality
+JOIN numeric_parent_scale2 p FOR KEY (id) <- c (parent_id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 4: JOIN numeric_parent_scale2 p FOR KEY (id) <- c (parent_id);
+                                     ^
+DETAIL:  Not every c (parent_id) value can be proven to have a matching p row. Referenced relation p is filtered before this key join. The relevant operation occurs inside view mf.numeric_parent_scale2.
+SELECT id, parent_id FROM numeric_child_scale2;
+ id | parent_id 
+----+-----------
+  1 |      1.00
+(1 row)
+
+CREATE VIEW numeric_parent_text AS
+    SELECT * FROM numeric_parent WHERE id::text = '1.00';
+CREATE VIEW numeric_child_text AS
+    SELECT * FROM numeric_child WHERE parent_id::text = '1.00';
+SELECT id, parent_id FROM numeric_child_text;
+ id | parent_id 
+----+-----------
+  1 |      1.00
+(1 row)
+
+SELECT c.id, p.id
+FROM numeric_child_text c
+-- rejected, reason: text coercion can distinguish values considered equal by FK equality
+JOIN numeric_parent_text p FOR KEY (id) <- c (parent_id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 4: JOIN numeric_parent_text p FOR KEY (id) <- c (parent_id);
+                                   ^
+DETAIL:  Not every c (parent_id) value can be proven to have a matching p row. Referenced relation p is filtered before this key join. The relevant operation occurs inside view mf.numeric_parent_text.
+DROP VIEW numeric_child_text, numeric_parent_text;
+DROP VIEW numeric_child_scale2, numeric_parent_scale2;
+DROP TABLE numeric_child, numeric_parent;
+-- M26b: Numeric custom-operator counterexample -- rejected, reason: a
+-- user-defined numeric equality filter can distinguish values that the FK
+-- equality operator treats as equal.
+CREATE FUNCTION numeric_text_eq(numeric, numeric) RETURNS boolean
+    LANGUAGE sql IMMUTABLE AS $$ SELECT $1::text OPERATOR(pg_catalog.=) $2::text $$;
+CREATE OPERATOR = (
+    LEFTARG = numeric,
+    RIGHTARG = numeric,
+    FUNCTION = numeric_text_eq
+);
+CREATE TABLE numeric_op_parent (
+    id numeric PRIMARY KEY
+);
+CREATE TABLE numeric_op_child (
+    id        integer PRIMARY KEY,
+    parent_id numeric NOT NULL REFERENCES numeric_op_parent (id)
+);
+INSERT INTO numeric_op_parent VALUES (1.0);
+INSERT INTO numeric_op_child VALUES (1, 1.00);
+CREATE VIEW numeric_op_parent_v AS
+    SELECT * FROM numeric_op_parent WHERE id OPERATOR(mf.=) 1.00;
+CREATE VIEW numeric_op_child_v AS
+    SELECT * FROM numeric_op_child WHERE parent_id OPERATOR(mf.=) 1.00;
+SELECT id, parent_id FROM numeric_op_child_v;
+ id | parent_id 
+----+-----------
+  1 |      1.00
+(1 row)
+
+SELECT id FROM numeric_op_parent_v;
+ id 
+----
+(0 rows)
+
+SELECT c.id, p.id
+FROM numeric_op_child_v c
+-- rejected, reason: mf.= is not the FK equality operator
+JOIN numeric_op_parent_v p FOR KEY (id) <- c (parent_id);
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven
+LINE 4: JOIN numeric_op_parent_v p FOR KEY (id) <- c (parent_id);
+                                   ^
+DETAIL:  Not every c (parent_id) value can be proven to have a matching p row. Referenced relation p is filtered before this key join. The relevant operation occurs inside view mf.numeric_op_parent_v.
+DROP VIEW numeric_op_child_v, numeric_op_parent_v;
+DROP TABLE numeric_op_child, numeric_op_parent;
+DROP OPERATOR mf.= (numeric, numeric);
+DROP FUNCTION numeric_text_eq(numeric, numeric);
+-- M27: Currently-unsupported compound expression node types -- rejected, reason:
+-- CollateExpr is not direct key equality.
+-- This conjunct wraps the FK column tenant_id in an explicit COLLATE,
+-- which produces a CollateExpr.  CollateExpr is not on the
+-- direct-equality evidence path.  The default-reject policy correctly rejects
+-- this conjunct as having a filter the rule cannot verify.
+CREATE VIEW c_unsupported AS
+    SELECT * FROM customers
+    WHERE (tenant_id::text COLLATE "C") = '1';
+CREATE VIEW o_unsupported AS
+    SELECT * FROM orders
+    WHERE (tenant_id::text COLLATE "C") = '1';
+SELECT o.id
+FROM o_unsupported o
+-- rejected, reason: collated expression is not supported for matched filters
+JOIN c_unsupported c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+ERROR:  key join from referencing relation o to referenced relation c cannot be proven
+LINE 4: JOIN c_unsupported c FOR KEY (tenant_id, id) <- o (tenant_id...
+                             ^
+DETAIL:  Not every o (tenant_id, customer_id) value can be proven to have a matching c row. Referenced relation c is filtered before this key join. The relevant operation occurs inside view mf.c_unsupported.
+DROP VIEW o_unsupported, c_unsupported;
+-- M28: Filter canonicalization must be RTE-aware -- rejected, reason:
+-- the filter is on the child side's id column, not on the projected parent
+-- key.  A varattno-only canonicalizer mistakes ch.id for p.id because both
+-- are attno 1 in their own RTEs, and then falsely treats q.id as
+-- row-covered under q.id = 10.
+CREATE TABLE rte_filter_parent (
+    id integer PRIMARY KEY
+);
+CREATE TABLE rte_filter_child (
+    id        integer PRIMARY KEY,
+    parent_id integer NOT NULL UNIQUE REFERENCES rte_filter_parent (id)
+);
+CREATE TABLE rte_filter_review (
+    id        integer PRIMARY KEY,
+    parent_id integer NOT NULL REFERENCES rte_filter_parent (id)
+);
+INSERT INTO rte_filter_parent VALUES (1), (10);
+INSERT INTO rte_filter_child VALUES (10, 1);
+INSERT INTO rte_filter_review VALUES (1, 10);
+CREATE VIEW rte_filter_review_10 AS
+    SELECT * FROM rte_filter_review WHERE parent_id = 10;
+SELECT r.id, q.id
+FROM (
+    SELECT p.id
+    FROM rte_filter_parent p
+    LEFT JOIN rte_filter_child ch FOR KEY (parent_id) -> p (id)
+    WHERE ch.id = 10
+) q
+-- rejected, reason: q is filtered by ch.id, not by q.id
+JOIN rte_filter_review_10 r FOR KEY (parent_id) -> q (id);
+ERROR:  key join from referencing relation r to referenced relation q cannot be proven
+LINE 9: JOIN rte_filter_review_10 r FOR KEY (parent_id) -> q (id);
+                                    ^
+DETAIL:  Not every r (parent_id) value can be proven to have a matching q row. Referenced relation q is filtered before this key join.
+DROP VIEW rte_filter_review_10;
+DROP TABLE rte_filter_review, rte_filter_child, rte_filter_parent;
+-- M29: Referencing-side filter evidence must not propagate to an
+-- outer-preserved referenced side.  The LEFT/RIGHT subqueries still contain
+-- parent id = 2, so they must not be treated as filtered to id = 1.
+CREATE TABLE outer_filter_root (
+    id integer PRIMARY KEY
+);
+CREATE TABLE outer_filter_parent (
+    id integer PRIMARY KEY REFERENCES outer_filter_root (id)
+);
+CREATE TABLE outer_filter_child (
+    id        integer PRIMARY KEY,
+    parent_id integer NOT NULL REFERENCES outer_filter_parent (id)
+);
+INSERT INTO outer_filter_root VALUES (1), (2);
+INSERT INTO outer_filter_parent VALUES (1), (2);
+INSERT INTO outer_filter_child VALUES (10, 1);
+CREATE VIEW outer_filter_root_1 AS
+    SELECT * FROM outer_filter_root WHERE id = 1;
+CREATE VIEW outer_filter_child_1 AS
+    SELECT * FROM outer_filter_child WHERE parent_id = 1;
+SELECT q.id
+FROM (
+    SELECT p.id
+    FROM outer_filter_parent p
+    LEFT JOIN outer_filter_child_1 c FOR KEY (parent_id) -> p (id)
+) q
+-- rejected, reason: LEFT JOIN preserves unmatched parent rows
+JOIN outer_filter_root_1 r FOR KEY (id) <- q (id);
+ERROR:  key join from referencing relation q to referenced relation r cannot be proven
+LINE 8: JOIN outer_filter_root_1 r FOR KEY (id) <- q (id);
+                                   ^
+DETAIL:  Not every q (id) value can be proven to have a matching r row. Referenced relation r has a filter that is not matched by referencing relation q.
+SELECT q.id
+FROM (
+    SELECT p.id
+    FROM outer_filter_child_1 c
+    RIGHT JOIN outer_filter_parent p FOR KEY (id) <- c (parent_id)
+) q
+-- rejected, reason: RIGHT JOIN preserves unmatched parent rows
+JOIN outer_filter_root_1 r FOR KEY (id) <- q (id);
+ERROR:  key join from referencing relation q to referenced relation r cannot be proven
+LINE 8: JOIN outer_filter_root_1 r FOR KEY (id) <- q (id);
+                                   ^
+DETAIL:  Not every q (id) value can be proven to have a matching r row. Referenced relation r has a filter that is not matched by referencing relation q.
+SELECT q.id
+FROM (
+    SELECT p.id
+    FROM outer_filter_parent p
+    JOIN outer_filter_child_1 c FOR KEY (parent_id) -> p (id)
+) q
+-- accepted, reason: INNER JOIN keeps only matched parent rows
+JOIN outer_filter_root_1 r FOR KEY (id) <- q (id)
+ORDER BY q.id;
+ id 
+----
+  1
+(1 row)
+
+DROP VIEW outer_filter_child_1, outer_filter_root_1;
+DROP TABLE outer_filter_child, outer_filter_parent, outer_filter_root;
+-- M30: Sublinks are not safe matched-filter conjuncts -- rejected, reason:
+-- matching key = (SELECT ...) predicates must not be retained as
+-- direct equality filter evidence.
+CREATE VIEW c_sublink AS
+    SELECT * FROM customers WHERE id = (SELECT 100);
+CREATE VIEW o_sublink AS
+    SELECT * FROM orders WHERE customer_id = (SELECT 100);
+SELECT o.id, c.name
+FROM o_sublink o
+-- rejected, reason: matched filters contain sublinks
+JOIN c_sublink c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+ERROR:  key join from referencing relation o to referenced relation c cannot be proven
+LINE 4: JOIN c_sublink c FOR KEY (tenant_id, id) <- o (tenant_id, cu...
+                         ^
+DETAIL:  Not every o (tenant_id, customer_id) value can be proven to have a matching c row. Referenced relation c is filtered before this key join. The relevant operation occurs inside view mf.c_sublink.
+DROP VIEW o_sublink, c_sublink;
+-- M31: Cross-type FK key joins are rejected by key-join proof.  PostgreSQL
+-- accepts the FK, but key join does not compute proof facts from it.
+-- Supporting this would also require matched-filter proof to compare
+-- filter evidence across different type-specific equality operators.
+CREATE TABLE cross_type_customers (
+    tenant_id smallint NOT NULL,
+    id        integer  NOT NULL,
+    name      text     NOT NULL,
+    PRIMARY KEY (tenant_id, id)
+);
+CREATE TABLE cross_type_orders (
+    tenant_id   bigint  NOT NULL,
+    id          integer NOT NULL,
+    customer_id integer NOT NULL,
+    PRIMARY KEY (tenant_id, id),
+    FOREIGN KEY (tenant_id, customer_id)
+        REFERENCES cross_type_customers (tenant_id, id)
+);
+INSERT INTO cross_type_customers VALUES (1, 1, 'Alice'), (2, 1, 'Carol');
+INSERT INTO cross_type_orders VALUES (1, 10, 1), (2, 20, 1);
+SELECT o.id, c.name
+FROM cross_type_orders o
+-- rejected, reason: FK and PK key types differ
+JOIN cross_type_customers c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id)
+ORDER BY o.id;
+ERROR:  key join from referencing relation o to referenced relation c cannot be proven
+LINE 4: JOIN cross_type_customers c FOR KEY (tenant_id, id) <- o (te...
+                                    ^
+DETAIL:  There is no matching foreign key constraint for o (tenant_id, customer_id) referencing c (tenant_id, id).
+CREATE VIEW cross_type_customers_1 AS
+    SELECT * FROM cross_type_customers WHERE tenant_id = 1;
+CREATE VIEW cross_type_orders_1 AS
+    SELECT * FROM cross_type_orders WHERE tenant_id = 1;
+SELECT o.id, c.name
+FROM cross_type_orders_1 o
+-- rejected, reason: FK and PK key types differ
+JOIN cross_type_customers_1 c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+ERROR:  key join from referencing relation o to referenced relation c cannot be proven
+LINE 4: JOIN cross_type_customers_1 c FOR KEY (tenant_id, id) <- o (...
+                                      ^
+DETAIL:  There is no matching foreign key constraint for o (tenant_id, customer_id) referencing c (tenant_id, id).
+DROP VIEW cross_type_orders_1, cross_type_customers_1;
+DROP TABLE cross_type_orders, cross_type_customers;
+-- Cleanup
+RESET mf.tenant;
+DROP VIEW tenant_orders, tenant_customers;
+DROP TABLE orders, customers;
+DROP SCHEMA mf;
+RESET search_path;
diff --git a/src/test/regress/expected/key_join_mcdc.out b/src/test/regress/expected/key_join_mcdc.out
new file mode 100644
index 00000000000..dfc9942bd28
--- /dev/null
+++ b/src/test/regress/expected/key_join_mcdc.out
@@ -0,0 +1,745 @@
+--
+-- key_join_mcdc
+--
+-- Focused local MC/DC coverage for build_key_join_quals(), its callers, and
+-- the equality-operator usability pre-guard.
+--
+\set VERBOSITY terse
+CREATE SCHEMA key_join_mcdc;
+SET search_path = key_join_mcdc, public;
+CREATE TABLE mcdc_parent
+(
+    id        int PRIMARY KEY,
+    tenant_id int NOT NULL,
+    code      int NOT NULL,
+    UNIQUE (tenant_id, code)
+);
+CREATE TABLE mcdc_child
+(
+    id        int PRIMARY KEY,
+    parent_id int UNIQUE NOT NULL REFERENCES mcdc_parent (id),
+    tenant_id int NOT NULL,
+    code      int NOT NULL,
+    UNIQUE (tenant_id, code),
+    FOREIGN KEY (tenant_id, code) REFERENCES mcdc_parent (tenant_id, code)
+);
+CREATE TABLE mcdc_reader
+(
+    id        int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES mcdc_parent (id),
+    tenant_id int NOT NULL,
+    code      int NOT NULL,
+    FOREIGN KEY (tenant_id, code) REFERENCES mcdc_parent (tenant_id, code)
+);
+CREATE TABLE mcdc_noinherit_child
+(
+    id        int PRIMARY KEY,
+    parent_id int,
+    NOT NULL parent_id NO INHERIT,
+    FOREIGN KEY (parent_id) REFERENCES mcdc_parent (id)
+);
+CREATE TABLE mcdc_self_ref
+(
+    id int PRIMARY KEY REFERENCES mcdc_self_ref (id)
+);
+INSERT INTO mcdc_parent VALUES (1, 10, 100), (2, 20, 200);
+INSERT INTO mcdc_child VALUES (11, 1, 10, 100), (22, 2, 20, 200);
+INSERT INTO mcdc_reader VALUES (101, 1, 10, 100), (202, 2, 20, 200);
+INSERT INTO mcdc_noinherit_child VALUES (111, 1), (222, 2);
+INSERT INTO mcdc_self_ref VALUES (1), (2);
+CREATE FUNCTION mcdc_positive_int(int) RETURNS boolean
+LANGUAGE sql IMMUTABLE STRICT
+AS $$ SELECT $1 > 0 $$;
+CREATE OPERATOR @+ (
+    RIGHTARG = int,
+    FUNCTION = mcdc_positive_int
+);
+-- Expression indexes split table-column self dependencies from function deps.
+CREATE INDEX mcdc_parent_positive_idx
+    ON mcdc_parent ((mcdc_positive_int(id)));
+DROP INDEX mcdc_parent_positive_idx;
+CREATE INDEX mcdc_parent_positive_partial_idx
+    ON mcdc_parent (id) WHERE mcdc_positive_int(id);
+DROP INDEX mcdc_parent_positive_partial_idx;
+CREATE TABLE mcdc_partition_expr_parent
+(
+    id int NOT NULL
+) PARTITION BY RANGE ((id + 0));
+DROP TABLE mcdc_partition_expr_parent;
+CREATE FUNCTION mcdc_stable_int_eq(a int, b int) RETURNS boolean
+LANGUAGE sql STABLE STRICT
+AS $$ SELECT a = b $$;
+CREATE OPERATOR === (
+    LEFTARG = int,
+    RIGHTARG = int,
+    PROCEDURE = mcdc_stable_int_eq
+);
+CREATE FUNCTION mcdc_hash_bridge_eq(a int, b bigint) RETURNS boolean
+LANGUAGE sql IMMUTABLE STRICT
+AS $$ SELECT a = b::int $$;
+CREATE OPERATOR =## (
+    LEFTARG = int,
+    RIGHTARG = bigint,
+    PROCEDURE = mcdc_hash_bridge_eq
+);
+CREATE OPERATOR FAMILY mcdc_btree_bridge_ops USING btree;
+ALTER OPERATOR FAMILY mcdc_btree_bridge_ops USING btree ADD
+    OPERATOR 3 =## (int, bigint);
+CREATE OPERATOR FAMILY mcdc_hash_bridge_ops USING hash;
+CREATE OPERATOR CLASS mcdc_hash_bridge_int_ops
+FOR TYPE int USING hash FAMILY mcdc_hash_bridge_ops AS
+    OPERATOR 1 = (int, int),
+    FUNCTION 1 hashint4(int);
+ALTER OPERATOR FAMILY mcdc_hash_bridge_ops USING hash ADD
+    OPERATOR 1 =## (int, bigint),
+    FUNCTION 1 hashint8(bigint);
+-- The nondeterministic-collation GROUP BY case is exercised in key_join_icu.
+CREATE TABLE mcdc_text_cast_parent
+(
+    id text PRIMARY KEY
+);
+CREATE TABLE mcdc_text_cast_reader
+(
+    id        int PRIMARY KEY,
+    parent_id text NOT NULL REFERENCES mcdc_text_cast_parent (id)
+);
+CREATE TABLE mcdc_regclass_parent
+(
+    id regclass PRIMARY KEY
+);
+CREATE TABLE mcdc_regclass_reader
+(
+    id        int PRIMARY KEY,
+    parent_id regclass NOT NULL REFERENCES mcdc_regclass_parent (id)
+);
+CREATE DOMAIN mcdc_num_dom AS numeric(4,0);
+CREATE TABLE mcdc_numdom_parent
+(
+    id mcdc_num_dom PRIMARY KEY
+);
+CREATE TABLE mcdc_numdom_reader
+(
+    id        int PRIMARY KEY,
+    parent_id mcdc_num_dom NOT NULL REFERENCES mcdc_numdom_parent (id)
+);
+CREATE DOMAIN mcdc_sig_dom AS int;
+CREATE FUNCTION mcdc_sig_dom_cmp(a mcdc_sig_dom, b mcdc_sig_dom)
+RETURNS int LANGUAGE sql IMMUTABLE STRICT
+AS $$ SELECT btint4cmp(a::int, b::int) $$;
+CREATE FUNCTION mcdc_sig_dom_eq(a mcdc_sig_dom, b mcdc_sig_dom)
+RETURNS boolean LANGUAGE sql IMMUTABLE STRICT
+AS $$ SELECT a::int = b::int $$;
+CREATE OPERATOR === (
+    LEFTARG = mcdc_sig_dom,
+    RIGHTARG = mcdc_sig_dom,
+    PROCEDURE = mcdc_sig_dom_eq,
+    COMMUTATOR = ===,
+    RESTRICT = eqsel,
+    JOIN = eqjoinsel,
+    MERGES
+);
+CREATE OPERATOR CLASS mcdc_sig_dom_ops
+FOR TYPE mcdc_sig_dom USING btree AS
+    OPERATOR 3 === (mcdc_sig_dom, mcdc_sig_dom),
+    FUNCTION 1 mcdc_sig_dom_cmp(mcdc_sig_dom, mcdc_sig_dom);
+CREATE TABLE mcdc_sig_parent
+(
+    id mcdc_sig_dom
+);
+CREATE UNIQUE INDEX mcdc_sig_parent_idx
+    ON mcdc_sig_parent USING btree (id mcdc_sig_dom_ops);
+CREATE TABLE mcdc_sig_child
+(
+    id        int PRIMARY KEY,
+    parent_id mcdc_sig_dom NOT NULL REFERENCES mcdc_sig_parent (id)
+);
+INSERT INTO mcdc_text_cast_parent VALUES ('a'), ('b');
+INSERT INTO mcdc_text_cast_reader VALUES (401, 'a'), (402, 'b');
+INSERT INTO mcdc_regclass_parent VALUES ('pg_class'), ('pg_type');
+INSERT INTO mcdc_regclass_reader VALUES (451, 'pg_class'), (452, 'pg_type');
+INSERT INTO mcdc_numdom_parent VALUES (1), (2);
+INSERT INTO mcdc_numdom_reader VALUES (501, 1), (502, 2);
+INSERT INTO mcdc_sig_parent VALUES (1), (2);
+INSERT INTO mcdc_sig_child VALUES (601, 1), (602, 2);
+CREATE FUNCTION mcdc_varchar_eq(a varchar, b varchar) RETURNS boolean
+LANGUAGE sql IMMUTABLE STRICT
+AS $$ SELECT a::text = b::text $$;
+CREATE OPERATOR === (
+    LEFTARG = varchar,
+    RIGHTARG = varchar,
+    PROCEDURE = mcdc_varchar_eq
+);
+CREATE FUNCTION mcdc_varchar_cmp(a varchar, b varchar) RETURNS int
+LANGUAGE sql IMMUTABLE STRICT
+AS $$ SELECT bttextcmp(a::text, b::text) $$;
+CREATE OPERATOR CLASS mcdc_varchar_ops
+FOR TYPE varchar USING btree AS
+    OPERATOR 3 === (varchar, varchar),
+    FUNCTION 1 mcdc_varchar_cmp(varchar, varchar);
+CREATE TABLE mcdc_varchar_parent
+(
+    id varchar(4) NOT NULL
+);
+CREATE UNIQUE INDEX mcdc_varchar_parent_custom_idx
+    ON mcdc_varchar_parent USING btree (id mcdc_varchar_ops);
+ALTER TABLE mcdc_varchar_parent ADD PRIMARY KEY (id);
+CREATE TABLE mcdc_varchar_child
+(
+    id        int PRIMARY KEY,
+    parent_id varchar(4) NOT NULL REFERENCES mcdc_varchar_parent (id)
+);
+CREATE DOMAIN mcdc_varchar_sig_dom AS varchar(4);
+CREATE FUNCTION mcdc_varchar_sig_dom_cmp(a mcdc_varchar_sig_dom,
+                                         b mcdc_varchar_sig_dom)
+RETURNS int LANGUAGE sql IMMUTABLE STRICT
+AS $$ SELECT bttextcmp(a::text, b::text) $$;
+CREATE FUNCTION mcdc_varchar_sig_dom_eq(a mcdc_varchar_sig_dom,
+                                        b mcdc_varchar_sig_dom)
+RETURNS boolean LANGUAGE sql IMMUTABLE STRICT
+AS $$ SELECT a::text = b::text $$;
+CREATE OPERATOR === (
+    LEFTARG = mcdc_varchar_sig_dom,
+    RIGHTARG = mcdc_varchar_sig_dom,
+    PROCEDURE = mcdc_varchar_sig_dom_eq,
+    COMMUTATOR = ===,
+    RESTRICT = eqsel,
+    JOIN = eqjoinsel,
+    MERGES
+);
+CREATE OPERATOR CLASS mcdc_varchar_sig_dom_ops
+FOR TYPE mcdc_varchar_sig_dom USING btree AS
+    OPERATOR 3 === (mcdc_varchar_sig_dom, mcdc_varchar_sig_dom),
+    FUNCTION 1 mcdc_varchar_sig_dom_cmp(mcdc_varchar_sig_dom,
+                                        mcdc_varchar_sig_dom);
+CREATE TABLE mcdc_varchar_sig_parent
+(
+    id mcdc_varchar_sig_dom
+);
+CREATE UNIQUE INDEX mcdc_varchar_sig_parent_idx
+    ON mcdc_varchar_sig_parent USING btree (id mcdc_varchar_sig_dom_ops);
+CREATE TABLE mcdc_varchar_sig_child
+(
+    id        int PRIMARY KEY,
+    parent_id mcdc_varchar_sig_dom NOT NULL
+        REFERENCES mcdc_varchar_sig_parent (id)
+);
+INSERT INTO mcdc_varchar_parent VALUES ('aa'), ('bb');
+INSERT INTO mcdc_varchar_child VALUES (901, 'aa'), (902, 'bb');
+INSERT INTO mcdc_varchar_sig_parent VALUES ('aa'), ('bb');
+INSERT INTO mcdc_varchar_sig_child VALUES (911, 'aa'), (912, 'bb');
+CREATE VIEW mcdc_text_cast_filter_v AS
+SELECT *
+FROM mcdc_text_cast_parent
+WHERE id::varchar === 'a'::varchar;
+CREATE VIEW mcdc_varchar_typmod_filter_v AS
+SELECT *
+FROM mcdc_varchar_parent
+WHERE id::varchar === 'aa'::varchar;
+CREATE VIEW mcdc_numdom_typmod_filter_v AS
+SELECT *
+FROM mcdc_numdom_parent
+WHERE id::numeric = 1;
+-- Live parse path: single-column and multi-column key quals.
+SELECT p.id, c.id
+FROM mcdc_parent p
+JOIN mcdc_child c FOR KEY (parent_id) -> p (id)
+ORDER BY p.id;
+ id | id 
+----+----
+  1 | 11
+  2 | 22
+(2 rows)
+
+SELECT p.tenant_id, p.code, c.id
+FROM mcdc_parent p
+JOIN mcdc_child c FOR KEY (tenant_id, code) -> p (tenant_id, code)
+ORDER BY p.tenant_id, p.code;
+ tenant_id | code | id 
+-----------+------+----
+        10 |  100 | 11
+        20 |  200 | 22
+(2 rows)
+
+SELECT p.id, c.id
+FROM mcdc_parent p
+JOIN mcdc_noinherit_child c FOR KEY (parent_id) -> p (id)
+ORDER BY p.id;
+ id | id  
+----+-----
+  1 | 111
+  2 | 222
+(2 rows)
+
+-- Self-join elimination must update the key-join arrow alias RT index.
+SET enable_self_join_elimination = on;
+SELECT s2.id
+FROM mcdc_self_ref s1
+JOIN mcdc_self_ref s2 FOR KEY (id) -> s1 (id)
+ORDER BY s2.id;
+ id 
+----
+  1
+  2
+(2 rows)
+
+RESET enable_self_join_elimination;
+-- Plain non-inner no-qual join path: no projected key facts.
+SELECT r.id, j.id
+FROM (
+    SELECT p.id
+    FROM mcdc_parent p
+    NATURAL LEFT JOIN (SELECT 1 AS extra) s
+) j
+JOIN mcdc_reader r FOR KEY (parent_id) -> j (id)
+ORDER BY r.id;
+ERROR:  key join from referencing relation r to referenced relation j cannot be proven at character 132
+-- Function ordinality path: no projected key facts.
+SELECT r.id, j.id
+FROM (
+    SELECT p.id
+    FROM mcdc_parent p
+    CROSS JOIN LATERAL generate_series(1, 1) WITH ORDINALITY AS g(x, n)
+) j
+JOIN mcdc_reader r FOR KEY (parent_id) -> j (id)
+ORDER BY r.id;
+ERROR:  key join from referencing relation r to referenced relation j cannot be proven at character 160
+-- Duplicate inactive unique facts preserve the first diagnostic source.
+CREATE TABLE mcdc_dupe_parent
+(
+    id int PRIMARY KEY
+);
+CREATE TABLE mcdc_dupe_reader
+(
+    id        int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES mcdc_dupe_parent (id)
+);
+CREATE TABLE mcdc_dupe_nullable_reader
+(
+    id        int PRIMARY KEY,
+    parent_id int REFERENCES mcdc_dupe_parent (id)
+);
+INSERT INTO mcdc_dupe_parent VALUES (1), (2);
+INSERT INTO mcdc_dupe_reader VALUES (701, 1), (702, 1), (703, 2);
+INSERT INTO mcdc_dupe_nullable_reader VALUES (801, 1), (802, NULL);
+CREATE VIEW mcdc_dupe_active_v AS
+SELECT DISTINCT p.id
+FROM mcdc_dupe_parent p;
+CREATE VIEW mcdc_dupe_inactive_nn_v AS
+SELECT r.parent_id
+FROM mcdc_dupe_parent p
+LEFT JOIN mcdc_dupe_reader r FOR KEY (parent_id) -> p (id);
+CREATE VIEW mcdc_dupe_v AS
+SELECT q.id
+FROM (SELECT DISTINCT p.id FROM mcdc_dupe_parent p) q
+LEFT JOIN mcdc_dupe_reader r FOR KEY (parent_id) -> q (id);
+SELECT r.id, v.id
+FROM mcdc_dupe_v v
+JOIN mcdc_dupe_reader r FOR KEY (parent_id) -> v (id)
+ORDER BY r.id, v.id;
+ERROR:  key join from referencing relation r to referenced relation v cannot be proven at character 62
+SELECT n.id, v.id
+FROM mcdc_dupe_active_v v
+JOIN mcdc_dupe_nullable_reader n FOR KEY (parent_id) -> v (id)
+ORDER BY n.id, v.id;
+ERROR:  key join from referencing relation n to referenced relation v cannot be proven at character 78
+SELECT rv.parent_id, v.id
+FROM mcdc_dupe_inactive_nn_v rv
+JOIN mcdc_dupe_active_v v FOR KEY (id) <- rv (parent_id)
+ORDER BY rv.parent_id, v.id;
+ERROR:  key join from referencing relation rv to referenced relation v cannot be proven at character 85
+-- Stored revalidation path: no FILTER, join-local FILTERs, and multi-column.
+CREATE VIEW mcdc_plain_v AS
+SELECT p.id
+FROM mcdc_parent p
+LEFT JOIN mcdc_child c FOR KEY (parent_id) -> p (id);
+CREATE VIEW mcdc_filter_v AS
+SELECT p.id
+FROM mcdc_parent p
+LEFT JOIN mcdc_child c FOR KEY (parent_id) -> p (id)
+FILTER (WHERE c.id > 0);
+CREATE VIEW mcdc_unary_filter_v AS
+SELECT p.id
+FROM mcdc_parent p
+LEFT JOIN mcdc_child c FOR KEY (parent_id) -> p (id)
+FILTER (WHERE @+ c.id);
+CREATE VIEW mcdc_other_side_filter_v AS
+SELECT p.id
+FROM mcdc_parent p
+LEFT JOIN mcdc_child c FOR KEY (parent_id) -> p (id)
+FILTER (WHERE p.id = 1);
+CREATE VIEW mcdc_duplicate_filter_parent_v AS
+SELECT *
+FROM mcdc_parent
+WHERE id = 1 AND id = 1;
+CREATE VIEW mcdc_duplicate_filter_reader_v AS
+SELECT *
+FROM mcdc_reader
+WHERE parent_id = 1;
+CREATE VIEW mcdc_group_inactive_rowcov_v AS
+SELECT p.id, p.tenant_id, p.code
+FROM mcdc_parent p
+GROUP BY p.id;
+CREATE VIEW mcdc_reader_parent_1_v AS
+SELECT *
+FROM mcdc_reader
+WHERE parent_id = 1;
+CREATE VIEW mcdc_group_inactive_rowcov_join_v AS
+SELECT r.parent_id
+FROM mcdc_group_inactive_rowcov_v v
+JOIN mcdc_reader_parent_1_v r FOR KEY (parent_id) -> v (id);
+CREATE VIEW mcdc_distinct_filter_v AS
+SELECT p.id
+FROM mcdc_parent p
+LEFT JOIN mcdc_child c FOR KEY (parent_id) -> p (id)
+FILTER (WHERE c.id = CASE WHEN 1 IS DISTINCT FROM 2 THEN 11 ELSE 0 END);
+CREATE VIEW mcdc_array_filter_v AS
+SELECT p.id
+FROM mcdc_parent p
+LEFT JOIN mcdc_child c FOR KEY (parent_id) -> p (id)
+FILTER (WHERE c.id = CASE WHEN 11 = ANY (ARRAY[11, 22]) THEN 11 ELSE 0 END);
+CREATE VIEW mcdc_expr_filter_v AS
+SELECT *
+FROM mcdc_parent
+WHERE id + 0 = 1;
+CREATE VIEW mcdc_relabel_expr_filter_v AS
+SELECT *
+FROM mcdc_parent
+WHERE (id::text)::varchar = '1';
+CREATE VIEW mcdc_stable_filter_v AS
+SELECT *
+FROM mcdc_parent
+WHERE id === 1;
+CREATE VIEW mcdc_hash_filter_parent_v AS
+SELECT *
+FROM mcdc_parent
+WHERE id =## 1::bigint;
+CREATE VIEW mcdc_hash_filter_reader_v AS
+SELECT *
+FROM mcdc_reader
+WHERE parent_id =## 1::bigint;
+CREATE VIEW mcdc_multi_v AS
+SELECT p.tenant_id, p.code
+FROM mcdc_parent p
+LEFT JOIN mcdc_child c FOR KEY (tenant_id, code) -> p (tenant_id, code);
+CREATE VIEW mcdc_second_cte_v AS
+WITH unused AS (SELECT id FROM mcdc_parent WHERE false),
+     parents AS (SELECT id FROM mcdc_parent)
+SELECT id FROM parents;
+CREATE VIEW mcdc_jointree_search_v AS
+SELECT q.id
+FROM (SELECT count(*) AS n FROM mcdc_parent) one,
+     (
+        (mcdc_parent p
+         LEFT JOIN mcdc_child c FOR KEY (parent_id) -> p (id))
+            AS q(id, tenant_id, code, child_id, child_parent_id,
+                 child_tenant_id, child_code)
+        JOIN mcdc_reader r FOR KEY (parent_id) -> q (id)
+     );
+CREATE VIEW mcdc_stored_plain_join_search_v AS
+SELECT v.id, c.id AS child_id
+FROM (mcdc_plain_v v
+      JOIN mcdc_reader r FOR KEY (parent_id) -> v (id))
+JOIN mcdc_child c ON c.parent_id = v.id;
+CREATE TABLE mcdc_rule_search_log (id int);
+CREATE RULE mcdc_rule_search_a AS ON DELETE TO mcdc_rule_search_log
+DO ALSO SELECT v.id, r.id AS reader_id
+FROM mcdc_plain_v v
+JOIN mcdc_reader r FOR KEY (parent_id) -> v (id);
+CREATE RULE mcdc_rule_search_b AS ON UPDATE TO mcdc_rule_search_log
+DO ALSO SELECT v.id, r.id AS reader_id
+FROM mcdc_plain_v v
+JOIN mcdc_reader r FOR KEY (parent_id) -> v (id);
+CREATE FUNCTION mcdc_rule_search_func() RETURNS TABLE(id int)
+LANGUAGE sql
+BEGIN ATOMIC
+    SELECT v.id
+    FROM mcdc_plain_v v
+    JOIN mcdc_reader r FOR KEY (parent_id) -> v (id);
+END;
+CREATE OR REPLACE VIEW mcdc_plain_v AS
+SELECT p.id
+FROM mcdc_parent p
+LEFT JOIN mcdc_child c FOR KEY (parent_id) -> p (id);
+DROP FUNCTION mcdc_rule_search_func();
+DROP RULE mcdc_rule_search_a ON mcdc_rule_search_log;
+DROP RULE mcdc_rule_search_b ON mcdc_rule_search_log;
+DROP TABLE mcdc_rule_search_log;
+SELECT r.id, v.id
+FROM mcdc_plain_v v
+JOIN mcdc_reader r FOR KEY (parent_id) -> v (id)
+ORDER BY r.id;
+ id  | id 
+-----+----
+ 101 |  1
+ 202 |  2
+(2 rows)
+
+SELECT r.id, v.id
+FROM mcdc_filter_v v
+JOIN mcdc_reader r FOR KEY (parent_id) -> v (id)
+ORDER BY r.id;
+ id  | id 
+-----+----
+ 101 |  1
+ 202 |  2
+(2 rows)
+
+SELECT r.id, v.id
+FROM mcdc_unary_filter_v v
+JOIN mcdc_reader r FOR KEY (parent_id) -> v (id)
+ORDER BY r.id;
+ id  | id 
+-----+----
+ 101 |  1
+ 202 |  2
+(2 rows)
+
+SELECT r.id, v.id
+FROM mcdc_other_side_filter_v v
+JOIN mcdc_reader r FOR KEY (parent_id) -> v (id)
+ORDER BY r.id;
+ id  | id 
+-----+----
+ 101 |  1
+ 202 |  2
+(2 rows)
+
+SELECT r.id, v.id
+FROM mcdc_duplicate_filter_parent_v v
+JOIN mcdc_duplicate_filter_reader_v r FOR KEY (parent_id) -> v (id)
+ORDER BY r.id;
+ id  | id 
+-----+----
+ 101 |  1
+(1 row)
+
+-- Filter propagation skips inactive row-coverage targets.
+SELECT q.parent_id, p.id
+FROM mcdc_group_inactive_rowcov_join_v q
+JOIN mcdc_parent p FOR KEY (id) <- q (parent_id)
+ORDER BY q.parent_id;
+ parent_id | id 
+-----------+----
+         1 |  1
+(1 row)
+
+SELECT r.id, v.id
+FROM mcdc_distinct_filter_v v
+JOIN mcdc_reader r FOR KEY (parent_id) -> v (id)
+ORDER BY r.id;
+ id  | id 
+-----+----
+ 101 |  1
+ 202 |  2
+(2 rows)
+
+SELECT r.id, v.id
+FROM mcdc_array_filter_v v
+JOIN mcdc_reader r FOR KEY (parent_id) -> v (id)
+ORDER BY r.id;
+ id  | id 
+-----+----
+ 101 |  1
+ 202 |  2
+(2 rows)
+
+SELECT r.id, v.id
+FROM mcdc_expr_filter_v v
+JOIN mcdc_reader r FOR KEY (parent_id) -> v (id)
+ORDER BY r.id;
+ERROR:  key join from referencing relation r to referenced relation v cannot be proven at character 64
+SELECT r.id, v.id
+FROM mcdc_relabel_expr_filter_v v
+JOIN mcdc_reader r FOR KEY (parent_id) -> v (id)
+ORDER BY r.id;
+ERROR:  key join from referencing relation r to referenced relation v cannot be proven at character 72
+SELECT r.id, v.id
+FROM mcdc_stable_filter_v v
+JOIN mcdc_reader r FOR KEY (parent_id) -> v (id)
+ORDER BY r.id;
+ERROR:  key join from referencing relation r to referenced relation v cannot be proven at character 66
+SELECT r.id, v.id
+FROM mcdc_hash_filter_parent_v v
+JOIN mcdc_hash_filter_reader_v r FOR KEY (parent_id) -> v (id)
+ORDER BY r.id;
+ id  | id 
+-----+----
+ 101 |  1
+(1 row)
+
+SELECT r.id, v.tenant_id, v.code
+FROM mcdc_multi_v v
+JOIN mcdc_reader r FOR KEY (tenant_id, code) -> v (tenant_id, code)
+ORDER BY r.id;
+ id  | tenant_id | code 
+-----+-----------+------
+ 101 |        10 |  100
+ 202 |        20 |  200
+(2 rows)
+
+SELECT r.id, v.id
+FROM mcdc_second_cte_v v
+JOIN mcdc_reader r FOR KEY (parent_id) -> v (id)
+ORDER BY r.id;
+ id  | id 
+-----+----
+ 101 |  1
+ 202 |  2
+(2 rows)
+
+SELECT r.id, v.id
+FROM mcdc_jointree_search_v v
+JOIN mcdc_reader r FOR KEY (parent_id) -> v (id)
+ORDER BY r.id;
+ERROR:  key join from referencing relation r to referenced relation v cannot be proven at character 68
+-- CTE projection path: skip an outer Var while projecting current key facts.
+SELECT p.id, probe.ok
+FROM mcdc_parent p,
+LATERAL (
+    WITH outer_cols AS (
+        SELECT p.id AS outer_id, c.parent_id
+        FROM mcdc_child c
+    )
+    SELECT true AS ok
+    FROM outer_cols oc
+    JOIN mcdc_parent pp FOR KEY (id) <- oc (parent_id)
+    WHERE oc.outer_id = p.id AND pp.id = p.id
+) probe
+ORDER BY p.id;
+ id | ok 
+----+----
+  1 | t
+  2 | t
+(2 rows)
+
+-- Query projection path: system columns are not projected key facts.
+SELECT r.id, q.id
+FROM (
+    SELECT p.id, p.ctid
+    FROM mcdc_parent p
+) q
+JOIN mcdc_reader r FOR KEY (parent_id) -> q (id)
+ORDER BY r.id;
+ id  | id 
+-----+----
+ 101 |  1
+ 202 |  2
+(2 rows)
+
+-- GROUP BY expression path: expression keys do not prove projected facts.
+SELECT r.id, g.id
+FROM (
+    SELECT p.id + 0 AS id
+    FROM mcdc_parent p
+    GROUP BY p.id + 0
+) g
+JOIN mcdc_reader r FOR KEY (parent_id) -> g (id)
+ORDER BY r.id;
+ERROR:  key join from referencing relation r to referenced relation g cannot be proven at character 120
+-- GROUP BY binary-compatible equality identity path: no projected key facts.
+SELECT rr.id, g.id
+FROM (
+    SELECT p.id
+    FROM mcdc_regclass_parent p
+    GROUP BY p.id
+) g
+JOIN mcdc_regclass_reader rr FOR KEY (parent_id) -> g (id)
+ORDER BY rr.id;
+ERROR:  key join from referencing relation rr to referenced relation g cannot be proven at character 126
+-- Non-domain relabel filters are not canonical key filters.
+SELECT vr.id, v.id
+FROM mcdc_text_cast_filter_v v
+JOIN mcdc_text_cast_reader vr FOR KEY (parent_id) -> v (id)
+ORDER BY vr.id;
+ERROR:  key join from referencing relation vr to referenced relation v cannot be proven at character 81
+-- Varchar typmod relabel filters are not canonical key filters.
+SELECT vc.id, v.id
+FROM mcdc_varchar_typmod_filter_v v
+JOIN mcdc_varchar_child vc FOR KEY (parent_id) -> v (id)
+ORDER BY vc.id;
+ERROR:  key join from referencing relation vc to referenced relation v cannot be proven at character 83
+-- Domain relabel filters must keep the normalized base typmod.
+SELECT nr.id, v.id
+FROM mcdc_numdom_typmod_filter_v v
+JOIN mcdc_numdom_reader nr FOR KEY (parent_id) -> v (id)
+ORDER BY nr.id;
+ERROR:  key join from referencing relation nr to referenced relation v cannot be proven at character 82
+-- Varchar opclasses can use either varchar or text equality input.
+SELECT c.id, g.id
+FROM (
+    SELECT p.id
+    FROM mcdc_varchar_parent p
+    GROUP BY p.id
+) g
+JOIN mcdc_varchar_child c FOR KEY (parent_id) -> g (id)
+ORDER BY c.id;
+ERROR:  key join from referencing relation c to referenced relation g cannot be proven at character 121
+-- Same-domain FK operators must use the normalized base equality type.
+SELECT c.id, p.id
+FROM mcdc_sig_parent p
+JOIN mcdc_sig_child c FOR KEY (parent_id) -> p (id)
+ORDER BY c.id;
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven at character 64
+-- Varchar-domain FK operators must use varchar or text equality input.
+SELECT c.id, p.id
+FROM mcdc_varchar_sig_parent p
+JOIN mcdc_varchar_sig_child c FOR KEY (parent_id) -> p (id)
+ORDER BY c.id;
+ERROR:  key join from referencing relation c to referenced relation p cannot be proven at character 80
+-- Procedure dependents without stored SQL bodies have nothing to revalidate.
+CREATE FUNCTION mcdc_support_func(internal)
+RETURNS internal
+LANGUAGE internal STRICT
+AS 'textlike_support';
+CREATE FUNCTION mcdc_support_subject(int) RETURNS int
+LANGUAGE sql STABLE SUPPORT mcdc_support_func
+AS $$ SELECT $1 $$;
+ALTER FUNCTION mcdc_support_func(internal) STABLE;
+DROP FUNCTION mcdc_support_subject(int);
+DROP FUNCTION mcdc_support_func(internal);
+DROP VIEW mcdc_duplicate_filter_reader_v;
+DROP VIEW mcdc_duplicate_filter_parent_v;
+DROP VIEW mcdc_group_inactive_rowcov_join_v;
+DROP VIEW mcdc_reader_parent_1_v;
+DROP VIEW mcdc_group_inactive_rowcov_v;
+DROP VIEW mcdc_stable_filter_v;
+DROP OPERATOR === (int, int);
+DROP FUNCTION mcdc_stable_int_eq(int, int);
+DROP VIEW mcdc_hash_filter_reader_v;
+DROP VIEW mcdc_hash_filter_parent_v;
+DROP OPERATOR CLASS mcdc_hash_bridge_int_ops USING hash;
+DROP OPERATOR FAMILY mcdc_hash_bridge_ops USING hash;
+DROP OPERATOR FAMILY mcdc_btree_bridge_ops USING btree;
+DROP OPERATOR =## (int, bigint);
+DROP FUNCTION mcdc_hash_bridge_eq(int, bigint);
+DROP VIEW mcdc_text_cast_filter_v;
+DROP TABLE mcdc_text_cast_reader, mcdc_text_cast_parent;
+DROP TABLE mcdc_regclass_reader, mcdc_regclass_parent;
+DROP VIEW mcdc_varchar_typmod_filter_v;
+DROP TABLE mcdc_varchar_child, mcdc_varchar_parent;
+DROP OPERATOR CLASS mcdc_varchar_ops USING btree;
+DROP OPERATOR FAMILY mcdc_varchar_ops USING btree;
+DROP FUNCTION mcdc_varchar_cmp(varchar, varchar);
+DROP OPERATOR === (varchar, varchar);
+DROP FUNCTION mcdc_varchar_eq(varchar, varchar);
+DROP TABLE mcdc_varchar_sig_child, mcdc_varchar_sig_parent;
+DROP OPERATOR CLASS mcdc_varchar_sig_dom_ops USING btree;
+DROP OPERATOR FAMILY mcdc_varchar_sig_dom_ops USING btree;
+DROP OPERATOR === (mcdc_varchar_sig_dom, mcdc_varchar_sig_dom);
+DROP FUNCTION mcdc_varchar_sig_dom_cmp(mcdc_varchar_sig_dom,
+                                       mcdc_varchar_sig_dom);
+DROP FUNCTION mcdc_varchar_sig_dom_eq(mcdc_varchar_sig_dom,
+                                      mcdc_varchar_sig_dom);
+DROP DOMAIN mcdc_varchar_sig_dom;
+DROP VIEW mcdc_numdom_typmod_filter_v;
+DROP TABLE mcdc_numdom_reader, mcdc_numdom_parent;
+DROP DOMAIN mcdc_num_dom;
+DROP TABLE mcdc_sig_child, mcdc_sig_parent;
+DROP OPERATOR CLASS mcdc_sig_dom_ops USING btree;
+DROP OPERATOR FAMILY mcdc_sig_dom_ops USING btree;
+DROP OPERATOR === (mcdc_sig_dom, mcdc_sig_dom);
+DROP FUNCTION mcdc_sig_dom_cmp(mcdc_sig_dom, mcdc_sig_dom);
+DROP FUNCTION mcdc_sig_dom_eq(mcdc_sig_dom, mcdc_sig_dom);
+DROP DOMAIN mcdc_sig_dom;
+DROP SCHEMA key_join_mcdc CASCADE;
+NOTICE:  drop cascades to 25 other objects
diff --git a/src/test/regress/expected/key_join_overview.out b/src/test/regress/expected/key_join_overview.out
new file mode 100644
index 00000000000..e5d8a71c010
--- /dev/null
+++ b/src/test/regress/expected/key_join_overview.out
@@ -0,0 +1,978 @@
+--
+-- key_join_overview
+--
+-- One clear example per category, organized by SQL feature.
+-- Shared fixture at top, all queries in the middle, all DROPs at bottom.
+--
+-- Referenced tables (PK side)
+CREATE TABLE ref_products (
+    prod_id   INTEGER PRIMARY KEY,
+    prod_name TEXT NOT NULL
+);
+CREATE TABLE ref_locations (
+    loc_country CHAR(2)     NOT NULL,
+    loc_zip     TEXT        NOT NULL,
+    PRIMARY KEY (loc_country, loc_zip)
+);
+CREATE TABLE ref_departments (
+    dept_id   INTEGER PRIMARY KEY,
+    dept_name TEXT NOT NULL
+);
+CREATE TABLE ref_teams (
+    team_id      INTEGER PRIMARY KEY,
+    team_dept_id INTEGER NOT NULL REFERENCES ref_departments(dept_id)
+);
+-- Referencing tables (FK side)
+CREATE TABLE fk_orders (
+    ord_id      INTEGER PRIMARY KEY,
+    ord_prod_id INTEGER NOT NULL REFERENCES ref_products(prod_id)
+);
+CREATE TABLE fk_reviews (
+    rev_id      INTEGER PRIMARY KEY,
+    rev_prod_id INTEGER NOT NULL REFERENCES ref_products(prod_id)
+);
+CREATE TABLE fk_warehouses (
+    wh_id      INTEGER PRIMARY KEY,
+    wh_country CHAR(2)     NOT NULL,
+    wh_zip     TEXT        NOT NULL,
+    FOREIGN KEY (wh_country, wh_zip) REFERENCES ref_locations(loc_country, loc_zip)
+);
+CREATE TABLE fk_employees (
+    emp_id      INTEGER PRIMARY KEY,
+    emp_team_id INTEGER NOT NULL REFERENCES ref_teams(team_id)
+);
+-- Special-purpose tables
+CREATE TABLE fk_returns (
+    ret_id      INTEGER PRIMARY KEY,
+    ret_prod_id INTEGER UNIQUE REFERENCES ref_products(prod_id)  -- nullable, unique
+);
+CREATE TABLE fk_inventory (
+    inv_id      INTEGER PRIMARY KEY,
+    inv_prod_id INTEGER NOT NULL UNIQUE REFERENCES ref_products(prod_id)
+);
+CREATE TABLE fk_deferrable_unique_inventory (
+    dui_id      INTEGER PRIMARY KEY,
+    dui_prod_id INTEGER NOT NULL REFERENCES ref_products(prod_id),
+    CONSTRAINT fk_deferrable_unique_inventory_uniq
+        UNIQUE (dui_prod_id) DEFERRABLE INITIALLY IMMEDIATE
+);
+-- For NOT VALID test
+CREATE TABLE fk_notvalid_child (
+    nv_id    INTEGER PRIMARY KEY,
+    nv_p_id  INTEGER
+);
+ALTER TABLE fk_notvalid_child
+    ADD CONSTRAINT fk_notvalid_child_fk
+    FOREIGN KEY (nv_p_id) REFERENCES ref_products(prod_id) NOT VALID;
+CREATE TABLE fk_notvalid_notnull_child (
+    nvn_id   INTEGER PRIMARY KEY,
+    nvn_p_id INTEGER REFERENCES ref_products(prod_id)
+);
+-- For NOT ENFORCED test
+CREATE TABLE fk_notenforced_child (
+    ne_id    INTEGER PRIMARY KEY,
+    ne_p_id  INTEGER NOT NULL,
+    FOREIGN KEY (ne_p_id) REFERENCES ref_products(prod_id) NOT ENFORCED
+);
+-- For deferred constraint-mode tests
+CREATE TABLE fk_deferrable_child (
+    df_id   INTEGER PRIMARY KEY,
+    df_p_id INTEGER NOT NULL,
+    CONSTRAINT fk_deferrable_child_fk
+        FOREIGN KEY (df_p_id) REFERENCES ref_products(prod_id)
+        DEFERRABLE INITIALLY IMMEDIATE
+);
+CREATE TABLE fk_initially_deferred_child (
+    did_id   INTEGER PRIMARY KEY,
+    did_p_id INTEGER NOT NULL,
+    CONSTRAINT fk_initially_deferred_child_fk
+        FOREIGN KEY (did_p_id) REFERENCES ref_products(prod_id)
+        DEFERRABLE INITIALLY DEFERRED
+);
+-- For materialized view test
+CREATE MATERIALIZED VIEW mv_products AS
+SELECT prod_id, prod_name FROM ref_products;
+-- For function tests
+CREATE FUNCTION fn_products() RETURNS TABLE (prod_id INTEGER, prod_name TEXT)
+LANGUAGE sql AS $$ SELECT prod_id, prod_name FROM ref_products $$;
+SET check_function_bodies = off;
+-- accepted, reason: function body is not checked while check_function_bodies is off
+CREATE FUNCTION fn_deferrable_key_join()
+RETURNS TABLE (prod_id INTEGER, df_id INTEGER)
+LANGUAGE sql STABLE AS $$
+    SELECT ref_products.prod_id, fk_deferrable_child.df_id
+    FROM ref_products
+    JOIN fk_deferrable_child FOR KEY (df_p_id) -> ref_products (prod_id)
+$$;
+RESET check_function_bodies;
+-- For ghost-row clearing test
+CREATE TABLE fko_clear_a (id INTEGER PRIMARY KEY);
+CREATE TABLE fko_clear_c (id INTEGER PRIMARY KEY);
+CREATE TABLE fko_clear_b (
+    id   INTEGER PRIMARY KEY,
+    a_id INTEGER NOT NULL REFERENCES fko_clear_a(id),
+    c_id INTEGER NOT NULL REFERENCES fko_clear_c(id)
+);
+CREATE TABLE fko_clear_d (
+    id   INTEGER PRIMARY KEY,
+    b_id INTEGER NOT NULL REFERENCES fko_clear_b(id)
+);
+INSERT INTO ref_products VALUES (1, 'Widget'), (2, 'Gadget'), (3, 'Doohickey');
+INSERT INTO ref_locations VALUES ('US', '10001'), ('DE', '80331'), ('JP', '10000');
+INSERT INTO ref_departments VALUES (10, 'Engineering'), (20, 'Sales');
+INSERT INTO ref_teams VALUES (100, 10), (200, 20);
+INSERT INTO fk_orders VALUES (1, 1), (2, 1), (3, 2);
+INSERT INTO fk_reviews VALUES (10, 1), (20, 3);
+INSERT INTO fk_warehouses VALUES (1, 'US', '10001'), (2, 'DE', '80331');
+INSERT INTO fk_employees VALUES (1000, 100), (2000, 200);
+INSERT INTO fk_returns VALUES (1, 1), (2, NULL);
+INSERT INTO fk_inventory VALUES (1, 1), (2, 2), (3, 3);
+INSERT INTO fk_deferrable_unique_inventory VALUES (1, 1), (2, 2), (3, 3);
+INSERT INTO fk_notvalid_child VALUES (1, 1), (2, 2);
+INSERT INTO fk_notvalid_notnull_child VALUES (1, 1), (2, NULL);
+ALTER TABLE fk_notvalid_notnull_child
+    ADD CONSTRAINT fk_notvalid_notnull_child_nn NOT NULL nvn_p_id NOT VALID;
+INSERT INTO fk_notenforced_child VALUES (1, 1), (2, 2);
+INSERT INTO fk_deferrable_child VALUES (1, 1), (2, 2);
+INSERT INTO fk_initially_deferred_child VALUES (1, 1), (2, 2);
+INSERT INTO fko_clear_a VALUES (1), (2), (3), (4);
+INSERT INTO fko_clear_c VALUES (10), (20), (30);
+INSERT INTO fko_clear_b VALUES (100, 1, 10), (200, 2, 20), (300, 3, 30);
+INSERT INTO fko_clear_d VALUES (1000, 100), (2000, 200);
+-- ============================================================
+-- 1. Basic syntax and directions
+-- ============================================================
+SELECT ref_products.prod_id, fk_orders.ord_id
+FROM ref_products
+-- accepted
+JOIN fk_orders FOR KEY (ord_prod_id) -> ref_products (prod_id)
+ORDER BY ord_id;
+ prod_id | ord_id 
+---------+--------
+       1 |      1
+       1 |      2
+       2 |      3
+(3 rows)
+
+SELECT ref_products.prod_id, fk_orders.ord_id
+FROM fk_orders
+-- accepted
+JOIN ref_products FOR KEY (prod_id) <- fk_orders (ord_prod_id)
+ORDER BY ord_id;
+ prod_id | ord_id 
+---------+--------
+       1 |      1
+       1 |      2
+       2 |      3
+(3 rows)
+
+-- rejected, reason: column count mismatch
+SELECT * FROM ref_products
+JOIN fk_orders FOR KEY (ord_prod_id, ord_id) -> ref_products (prod_id);
+ERROR:  key join column lists must have the same length
+LINE 2: JOIN fk_orders FOR KEY (ord_prod_id, ord_id) -> ref_products...
+                       ^
+-- rejected, reason: column not found
+SELECT * FROM ref_products
+JOIN fk_orders FOR KEY (nonexistent) -> ref_products (prod_id);
+ERROR:  referencing column "nonexistent" does not exist
+LINE 2: JOIN fk_orders FOR KEY (nonexistent) -> ref_products (prod_i...
+                       ^
+-- rejected, reason: no matching FK constraint (prod_name is not the referenced column)
+SELECT * FROM ref_products
+JOIN fk_orders FOR KEY (ord_prod_id) -> ref_products (prod_name);
+ERROR:  key join from referencing relation fk_orders to referenced relation ref_products cannot be proven
+LINE 2: JOIN fk_orders FOR KEY (ord_prod_id) -> ref_products (prod_n...
+                       ^
+DETAIL:  Referenced columns ref_products (prod_name) are not proven unique.
+-- rejected, reason: wrong direction (fk_orders has no FK referencing ref_products that way)
+SELECT * FROM fk_orders
+JOIN ref_products FOR KEY (prod_id) -> fk_orders (ord_prod_id);
+ERROR:  key join from referencing relation ref_products to referenced relation fk_orders cannot be proven
+LINE 2: JOIN ref_products FOR KEY (prod_id) -> fk_orders (ord_prod_i...
+                          ^
+DETAIL:  There is no matching foreign key constraint for ref_products (prod_id) referencing fk_orders (ord_prod_id).
+-- ============================================================
+-- 2. Join types (INNER, LEFT, RIGHT, FULL)
+-- ============================================================
+SELECT ref_products.prod_id, fk_orders.ord_id
+FROM ref_products
+-- accepted
+JOIN fk_orders FOR KEY (ord_prod_id) -> ref_products (prod_id)
+ORDER BY ord_id;
+ prod_id | ord_id 
+---------+--------
+       1 |      1
+       1 |      2
+       2 |      3
+(3 rows)
+
+SELECT ref_products.prod_id, fk_orders.ord_id
+FROM ref_products
+-- accepted
+LEFT JOIN fk_orders FOR KEY (ord_prod_id) -> ref_products (prod_id)
+ORDER BY ref_products.prod_id, ord_id;
+ prod_id | ord_id 
+---------+--------
+       1 |      1
+       1 |      2
+       2 |      3
+       3 |       
+(4 rows)
+
+SELECT ref_products.prod_id, fk_orders.ord_id
+FROM fk_orders
+-- accepted
+RIGHT JOIN ref_products FOR KEY (prod_id) <- fk_orders (ord_prod_id)
+ORDER BY ref_products.prod_id, ord_id;
+ prod_id | ord_id 
+---------+--------
+       1 |      1
+       1 |      2
+       2 |      3
+       3 |       
+(4 rows)
+
+SELECT ref_products.prod_id, fk_orders.ord_id
+FROM ref_products
+-- accepted
+FULL JOIN fk_orders FOR KEY (ord_prod_id) -> ref_products (prod_id)
+ORDER BY ref_products.prod_id, ord_id;
+ prod_id | ord_id 
+---------+--------
+       1 |      1
+       1 |      2
+       2 |      3
+       3 |       
+(4 rows)
+
+-- ============================================================
+-- 3. Composite foreign keys
+-- ============================================================
+SELECT ref_locations.loc_country, fk_warehouses.wh_id
+FROM ref_locations
+-- accepted
+JOIN fk_warehouses FOR KEY (wh_country, wh_zip) -> ref_locations (loc_country, loc_zip)
+ORDER BY wh_id;
+ loc_country | wh_id 
+-------------+-------
+ US          |     1
+ DE          |     2
+(2 rows)
+
+-- accepted, reason: swapped order on both sides
+SELECT ref_locations.loc_country, fk_warehouses.wh_id
+FROM ref_locations
+JOIN fk_warehouses FOR KEY (wh_zip, wh_country) -> ref_locations (loc_zip, loc_country)
+ORDER BY wh_id;
+ loc_country | wh_id 
+-------------+-------
+ US          |     1
+ DE          |     2
+(2 rows)
+
+-- rejected, reason: mismatched column order
+SELECT * FROM ref_locations
+JOIN fk_warehouses FOR KEY (wh_zip, wh_country) -> ref_locations (loc_country, loc_zip);
+ERROR:  key join from referencing relation fk_warehouses to referenced relation ref_locations cannot be proven
+LINE 2: JOIN fk_warehouses FOR KEY (wh_zip, wh_country) -> ref_locat...
+                           ^
+DETAIL:  The matching foreign key for fk_warehouses (wh_zip, wh_country) references different columns than ref_locations (loc_country, loc_zip).
+-- ============================================================
+-- 4. Derived tables (views, subqueries, CTEs)
+-- ============================================================
+-- accepted, reason: subquery wrapping base table as referenced side
+SELECT q.prod_id, fk_orders.ord_id
+FROM (SELECT * FROM ref_products) AS q
+JOIN fk_orders FOR KEY (ord_prod_id) -> q (prod_id)
+ORDER BY ord_id;
+ prod_id | ord_id 
+---------+--------
+       1 |      1
+       1 |      2
+       2 |      3
+(3 rows)
+
+-- accepted, reason: subquery on referencing side
+SELECT ref_products.prod_id, q.ord_id
+FROM ref_products
+JOIN (SELECT ord_id, ord_prod_id FROM fk_orders) AS q
+    FOR KEY (ord_prod_id) -> ref_products (prod_id)
+ORDER BY q.ord_id;
+ prod_id | ord_id 
+---------+--------
+       1 |      1
+       1 |      2
+       2 |      3
+(3 rows)
+
+-- accepted, reason: nested subquery with column renames
+SELECT q.p_id, q2.o_id
+FROM (SELECT prod_id AS p_id FROM (SELECT prod_id FROM ref_products) sub) AS q
+JOIN (SELECT ord_id AS o_id, ord_prod_id AS o_pid FROM fk_orders) AS q2
+    FOR KEY (o_pid) -> q (p_id)
+ORDER BY q2.o_id;
+ p_id | o_id 
+------+------
+    1 |    1
+    1 |    2
+    2 |    3
+(3 rows)
+
+-- accepted, reason: view with column renames as referenced side
+CREATE VIEW v_products_renamed AS
+    SELECT prod_id AS pid, prod_name AS pname FROM ref_products;
+SELECT v.pid, fk_orders.ord_id
+FROM v_products_renamed v
+-- accepted
+JOIN fk_orders FOR KEY (ord_prod_id) -> v (pid)
+ORDER BY ord_id;
+ pid | ord_id 
+-----+--------
+   1 |      1
+   1 |      2
+   2 |      3
+(3 rows)
+
+-- accepted, reason: view containing FOR KEY, used as referenced side by outer FOR KEY
+CREATE VIEW v_prods_with_inventory AS
+    SELECT ref_products.prod_id, ref_products.prod_name, fk_inventory.inv_id
+    FROM ref_products
+    LEFT JOIN fk_inventory FOR KEY (inv_prod_id) -> ref_products (prod_id);
+SELECT v.prod_id, fk_orders.ord_id
+FROM v_prods_with_inventory v
+-- accepted
+JOIN fk_orders FOR KEY (ord_prod_id) -> v (prod_id)
+ORDER BY ord_id;
+ prod_id | ord_id 
+---------+--------
+       1 |      1
+       1 |      2
+       2 |      3
+(3 rows)
+
+-- accepted, reason: CTE chain with column renames
+WITH
+cte1 (cte_pid, cte_pname) AS (SELECT prod_id, prod_name FROM ref_products),
+cte2 (c2_pid, c2_pname) AS (SELECT cte_pid, cte_pname FROM cte1)
+SELECT c2_pid, fk_orders.ord_id
+FROM cte2
+JOIN fk_orders FOR KEY (ord_prod_id) -> cte2 (c2_pid)
+ORDER BY ord_id;
+ c2_pid | ord_id 
+--------+--------
+      1 |      1
+      1 |      2
+      2 |      3
+(3 rows)
+
+-- rejected, reason: ordinary ON join in subquery does not expose key facts
+SELECT q.prod_id, fk_orders.ord_id
+FROM (
+    SELECT ref_products.prod_id
+    FROM ref_products
+    LEFT JOIN fk_inventory ON fk_inventory.inv_prod_id = ref_products.prod_id
+) q
+JOIN fk_orders FOR KEY (ord_prod_id) -> q (prod_id);
+ERROR:  key join from referencing relation fk_orders to referenced relation q cannot be proven
+LINE 7: JOIN fk_orders FOR KEY (ord_prod_id) -> q (prod_id);
+                       ^
+DETAIL:  There is no matching foreign key constraint for fk_orders (ord_prod_id) referencing q (prod_id).
+-- rejected, reason: ordinary USING join in subquery does not expose key facts
+SELECT q.prod_id, fk_orders.ord_id
+FROM (
+    SELECT p.prod_id
+    FROM ref_products p
+    LEFT JOIN (SELECT inv_prod_id AS prod_id FROM fk_inventory) i USING (prod_id)
+) q
+JOIN fk_orders FOR KEY (ord_prod_id) -> q (prod_id);
+ERROR:  key join from referencing relation fk_orders to referenced relation q cannot be proven
+LINE 7: JOIN fk_orders FOR KEY (ord_prod_id) -> q (prod_id);
+                       ^
+DETAIL:  There is no matching foreign key constraint for fk_orders (ord_prod_id) referencing q (prod_id).
+-- rejected, reason: ordinary CROSS JOIN with WHERE does not expose key facts
+SELECT q.prod_id, fk_orders.ord_id
+FROM (
+    SELECT ref_products.prod_id
+    FROM ref_products
+    CROSS JOIN fk_inventory
+    WHERE fk_inventory.inv_prod_id = ref_products.prod_id
+) q
+JOIN fk_orders FOR KEY (ord_prod_id) -> q (prod_id);
+ERROR:  key join from referencing relation fk_orders to referenced relation q cannot be proven
+LINE 8: JOIN fk_orders FOR KEY (ord_prod_id) -> q (prod_id);
+                       ^
+DETAIL:  There is no matching foreign key constraint for fk_orders (ord_prod_id) referencing q (prod_id).
+-- rejected, reason: comma join with WHERE does not expose key facts
+SELECT q.prod_id, fk_orders.ord_id
+FROM (
+    SELECT ref_products.prod_id
+    FROM ref_products, fk_inventory
+    WHERE fk_inventory.inv_prod_id = ref_products.prod_id
+) q
+JOIN fk_orders FOR KEY (ord_prod_id) -> q (prod_id);
+ERROR:  key join from referencing relation fk_orders to referenced relation q cannot be proven
+LINE 7: JOIN fk_orders FOR KEY (ord_prod_id) -> q (prod_id);
+                       ^
+DETAIL:  There is no matching foreign key constraint for fk_orders (ord_prod_id) referencing q (prod_id).
+-- rejected, reason: view with WHERE on referenced side (R violated)
+CREATE VIEW v_products_filtered AS
+    SELECT prod_id, prod_name FROM ref_products WHERE prod_id > 0;
+SELECT * FROM v_products_filtered v
+-- accepted
+JOIN fk_orders FOR KEY (ord_prod_id) -> v (prod_id);
+ERROR:  key join from referencing relation fk_orders to referenced relation v cannot be proven
+LINE 3: JOIN fk_orders FOR KEY (ord_prod_id) -> v (prod_id);
+                       ^
+DETAIL:  Not every fk_orders (ord_prod_id) value can be proven to have a matching v row. Referenced relation v is filtered before this key join. The relevant operation occurs inside view public.v_products_filtered.
+-- rejected, reason: subquery with LIMIT on referenced side (R violated)
+SELECT * FROM (SELECT prod_id FROM ref_products LIMIT 1) AS q
+JOIN fk_orders FOR KEY (ord_prod_id) -> q (prod_id);
+ERROR:  key join from referencing relation fk_orders to referenced relation q cannot be proven
+LINE 2: JOIN fk_orders FOR KEY (ord_prod_id) -> q (prod_id);
+                       ^
+DETAIL:  Not every fk_orders (ord_prod_id) value can be proven to have a matching q row. Referenced relation q may lose rows before this key join because of HAVING, LIMIT, OFFSET, FOR UPDATE or TABLESAMPLE.
+-- rejected, reason: TABLESAMPLE on referenced side (R violated)
+SELECT * FROM ref_products rp TABLESAMPLE SYSTEM (0)
+JOIN fk_orders FOR KEY (ord_prod_id) -> rp (prod_id);
+ERROR:  key join from referencing relation fk_orders to referenced relation rp cannot be proven
+LINE 2: JOIN fk_orders FOR KEY (ord_prod_id) -> rp (prod_id);
+                       ^
+DETAIL:  There is no matching foreign key constraint for fk_orders (ord_prod_id) referencing rp (prod_id).
+-- rejected, reason: wrapped TABLESAMPLE still does not preserve row coverage
+SELECT * FROM (SELECT prod_id FROM ref_products TABLESAMPLE SYSTEM (0)) AS q
+JOIN fk_orders FOR KEY (ord_prod_id) -> q (prod_id);
+ERROR:  key join from referencing relation fk_orders to referenced relation q cannot be proven
+LINE 2: JOIN fk_orders FOR KEY (ord_prod_id) -> q (prod_id);
+                       ^
+DETAIL:  Not every fk_orders (ord_prod_id) value can be proven to have a matching q row. Referenced relation q may lose rows before this key join because of HAVING, LIMIT, OFFSET, FOR UPDATE or TABLESAMPLE.
+-- rejected, reason: SKIP LOCKED on referenced side may remove rows at execution (R violated)
+SELECT * FROM (SELECT prod_id FROM ref_products FOR UPDATE SKIP LOCKED) AS q
+JOIN fk_orders FOR KEY (ord_prod_id) -> q (prod_id);
+ERROR:  key join from referencing relation fk_orders to referenced relation q cannot be proven
+LINE 2: JOIN fk_orders FOR KEY (ord_prod_id) -> q (prod_id);
+                       ^
+DETAIL:  Not every fk_orders (ord_prod_id) value can be proven to have a matching q row. Referenced relation q may lose rows before this key join because of HAVING, LIMIT, OFFSET, FOR UPDATE or TABLESAMPLE.
+-- rejected, reason: recursive CTE
+WITH RECURSIVE rcte AS (
+    SELECT prod_id FROM ref_products
+    UNION
+    SELECT prod_id FROM rcte
+)
+SELECT * FROM rcte JOIN fk_orders FOR KEY (ord_prod_id) -> rcte (prod_id);
+ERROR:  key join from referencing relation fk_orders to referenced relation rcte cannot be proven
+LINE 6: SELECT * FROM rcte JOIN fk_orders FOR KEY (ord_prod_id) -> r...
+                                          ^
+DETAIL:  There is no matching foreign key constraint for fk_orders (ord_prod_id) referencing rcte (prod_id).
+-- accepted, reason: two-level chain employees -> teams -> departments
+SELECT ref_departments.dept_name, ref_teams.team_id, fk_employees.emp_id
+FROM ref_departments
+JOIN
+    ref_teams JOIN fk_employees FOR KEY (emp_team_id) -> ref_teams (team_id)
+FOR KEY (team_dept_id) -> ref_departments (dept_id)
+ORDER BY emp_id;
+  dept_name  | team_id | emp_id 
+-------------+---------+--------
+ Engineering |     100 |   1000
+ Sales       |     200 |   2000
+(2 rows)
+
+-- ============================================================
+-- 5. GROUP BY / DISTINCT / DISTINCT ON
+-- ============================================================
+-- accepted, reason: LEFT JOIN + GROUP BY on PK restores uniqueness
+SELECT t3.rev_id, q.prod_id, q.cnt FROM
+(
+    SELECT ref_products.prod_id, COUNT(*) AS cnt
+    FROM ref_products
+    LEFT JOIN fk_orders FOR KEY (ord_prod_id) -> ref_products (prod_id)
+    GROUP BY ref_products.prod_id
+) q
+JOIN fk_reviews t3 FOR KEY (rev_prod_id) -> q (prod_id)
+ORDER BY t3.rev_id;
+ rev_id | prod_id | cnt 
+--------+---------+-----
+     10 |       1 |   2
+     20 |       3 |   1
+(2 rows)
+
+-- accepted, reason: DISTINCT on full PK restores uniqueness
+SELECT t3.rev_id, q.prod_id FROM
+(
+    SELECT DISTINCT ref_products.prod_id
+    FROM ref_products
+    LEFT JOIN fk_orders FOR KEY (ord_prod_id) -> ref_products (prod_id)
+) q
+JOIN fk_reviews t3 FOR KEY (rev_prod_id) -> q (prod_id)
+ORDER BY t3.rev_id;
+ rev_id | prod_id 
+--------+---------
+     10 |       1
+     20 |       3
+(2 rows)
+
+-- accepted, reason: DISTINCT ON covering PK
+SELECT t3.rev_id, q.prod_id FROM
+(
+    SELECT DISTINCT ON (ref_products.prod_id)
+        ref_products.prod_id, fk_orders.ord_id
+    FROM ref_products
+    LEFT JOIN fk_orders FOR KEY (ord_prod_id) -> ref_products (prod_id)
+) q
+JOIN fk_reviews t3 FOR KEY (rev_prod_id) -> q (prod_id)
+ORDER BY t3.rev_id;
+ rev_id | prod_id 
+--------+---------
+     10 |       1
+     20 |       3
+(2 rows)
+
+-- rejected, reason: GROUP BY on expression
+SELECT * FROM
+(
+    SELECT ref_products.prod_id + 1 AS expr_result
+    FROM ref_products
+    LEFT JOIN fk_orders FOR KEY (ord_prod_id) -> ref_products (prod_id)
+    GROUP BY ref_products.prod_id + 1
+) q
+JOIN fk_reviews FOR KEY (rev_prod_id) -> q (expr_result);
+ERROR:  key join from referencing relation fk_reviews to referenced relation q cannot be proven
+LINE 8: JOIN fk_reviews FOR KEY (rev_prod_id) -> q (expr_result);
+                        ^
+DETAIL:  Not every fk_reviews (rev_prod_id) value can be proven to have a matching q row. Referenced relation q is not proven to contain every referenced key row.
+-- rejected, reason: GROUP BY columns from different base tables
+SELECT * FROM
+(
+    SELECT ref_products.prod_id, fk_orders.ord_id
+    FROM ref_products
+    LEFT JOIN fk_orders FOR KEY (ord_prod_id) -> ref_products (prod_id)
+    GROUP BY ref_products.prod_id, fk_orders.ord_id
+) q
+JOIN fk_reviews FOR KEY (rev_prod_id) -> q (prod_id);
+ERROR:  key join from referencing relation fk_reviews to referenced relation q cannot be proven
+LINE 8: JOIN fk_reviews FOR KEY (rev_prod_id) -> q (prod_id);
+                        ^
+DETAIL:  Referenced columns q (prod_id) are not proven unique. A preceding join may duplicate rows from referenced relation q.
+-- rejected, reason: DISTINCT on partial composite PK
+SELECT * FROM
+(
+    SELECT DISTINCT loc_country
+    FROM ref_locations
+) q
+JOIN fk_warehouses FOR KEY (wh_country, wh_zip) -> q (loc_country, loc_zip);
+ERROR:  referenced column "loc_zip" does not exist
+LINE 6: JOIN fk_warehouses FOR KEY (wh_country, wh_zip) -> q (loc_co...
+                           ^
+-- rejected, reason: DISTINCT ON not covering PK
+SELECT * FROM
+(
+    SELECT DISTINCT ON (fk_orders.ord_prod_id)
+        ref_products.prod_id, fk_orders.ord_id
+    FROM ref_products
+    LEFT JOIN fk_orders FOR KEY (ord_prod_id) -> ref_products (prod_id)
+) q
+JOIN fk_reviews FOR KEY (rev_prod_id) -> q (prod_id);
+ERROR:  key join from referencing relation fk_reviews to referenced relation q cannot be proven
+LINE 8: JOIN fk_reviews FOR KEY (rev_prod_id) -> q (prod_id);
+                        ^
+DETAIL:  Referenced columns q (prod_id) are not proven unique. A preceding join may duplicate rows from referenced relation q.
+-- ============================================================
+-- 6. Outer join null semantics
+-- ============================================================
+-- accepted, reason: referenced on preserved side of LEFT JOIN
+SELECT COUNT(*)
+FROM (
+    SELECT ref_products.prod_id AS ref_id
+    FROM ref_products
+    LEFT JOIN fk_inventory FOR KEY (inv_prod_id) -> ref_products (prod_id)
+) q
+JOIN fk_orders FOR KEY (ord_prod_id) -> q (ref_id);
+ count 
+-------
+     3
+(1 row)
+
+-- accepted, reason: FULL JOIN may null-extend ref_products, but ghost rows with NULL
+-- ref_id never match the outer FK equi-join condition, so they neither
+-- break uniqueness on q.ref_id nor prevent any fk_orders row from
+-- finding its match.
+SELECT COUNT(*)
+FROM (
+    SELECT ref_products.prod_id AS ref_id
+    FROM ref_products
+    FULL JOIN fk_returns FOR KEY (ret_prod_id) -> ref_products (prod_id)
+) q
+JOIN fk_orders FOR KEY (ord_prod_id) -> q (ref_id);
+ count 
+-------
+     3
+(1 row)
+
+-- rejected, reason: FULL JOIN can null-extend the referencing side too, so
+-- a NOT NULL fact on a referencing column must not be propagated through it.
+-- fko_clear_a has id=4 with no matching fko_clear_b, so the FULL JOIN emits
+-- a row with fko_clear_b.c_id IS NULL.  An INNER key join chained on c_id
+-- would silently drop that null-extended row, violating preservation.
+SELECT *
+FROM fko_clear_a
+FULL JOIN fko_clear_b FOR KEY (a_id) -> fko_clear_a (id)
+JOIN fko_clear_c FOR KEY (id) <- fko_clear_b (c_id);
+ERROR:  key join from referencing relation fko_clear_b to referenced relation fko_clear_c cannot be proven
+LINE 4: JOIN fko_clear_c FOR KEY (id) <- fko_clear_b (c_id);
+                         ^
+DETAIL:  This inner join could filter rows from fko_clear_b. Referencing columns fko_clear_b (c_id) can be null because a preceding outer join can null-extend the referencing side.
+-- rejected, symmetric form: referencing on LEFT
+SELECT *
+FROM fko_clear_b
+FULL JOIN fko_clear_a FOR KEY (id) <- fko_clear_b (a_id)
+JOIN fko_clear_c FOR KEY (id) <- fko_clear_b (c_id);
+ERROR:  key join from referencing relation fko_clear_b to referenced relation fko_clear_c cannot be proven
+LINE 4: JOIN fko_clear_c FOR KEY (id) <- fko_clear_b (c_id);
+                         ^
+DETAIL:  This inner join could filter rows from fko_clear_b. Referencing columns fko_clear_b (c_id) can be null because a preceding outer join can null-extend the referencing side.
+-- accepted, sanity: INNER preserved-side JOIN does not null-extend, so the
+-- chained INNER key join on c_id is still provable.
+SELECT *
+FROM fko_clear_a
+JOIN fko_clear_b FOR KEY (a_id) -> fko_clear_a (id)
+JOIN fko_clear_c FOR KEY (id) <- fko_clear_b (c_id)
+ORDER BY fko_clear_b.id;
+ id | id  | a_id | c_id | id 
+----+-----+------+------+----
+  1 | 100 |    1 |   10 | 10
+  2 | 200 |    2 |   20 | 20
+  3 | 300 |    3 |   30 | 30
+(3 rows)
+
+-- rejected, reason: non-unique key join inside subquery (U violated)
+SELECT * FROM
+(
+    SELECT ref_products.prod_id, fk_orders.ord_id
+    FROM ref_products
+    JOIN fk_orders FOR KEY (ord_prod_id) -> ref_products (prod_id)
+) q
+JOIN fk_reviews FOR KEY (rev_prod_id) -> q (prod_id);
+ERROR:  key join from referencing relation fk_reviews to referenced relation q cannot be proven
+LINE 7: JOIN fk_reviews FOR KEY (rev_prod_id) -> q (prod_id);
+                        ^
+DETAIL:  Referenced columns q (prod_id) are not proven unique. A preceding join may duplicate rows from referenced relation q.
+-- rejected, reason: INNER JOIN inside subquery removes rows (R violated)
+SELECT * FROM
+(
+    SELECT ref_products.prod_id
+    FROM ref_products
+    JOIN fk_orders FOR KEY (ord_prod_id) -> ref_products (prod_id)
+    GROUP BY ref_products.prod_id
+) q
+JOIN fk_reviews FOR KEY (rev_prod_id) -> q (prod_id);
+ERROR:  key join from referencing relation fk_reviews to referenced relation q cannot be proven
+LINE 8: JOIN fk_reviews FOR KEY (rev_prod_id) -> q (prod_id);
+                        ^
+DETAIL:  Not every fk_reviews (rev_prod_id) value can be proven to have a matching q row. Referenced relation q may lose rows before this key join because of a preceding join.
+-- ============================================================
+-- 8. Constraint enforcement and timing
+-- ============================================================
+-- rejected, reason: NOT ENFORCED FK rejected
+SELECT ref_products.prod_id, fk_notenforced_child.ne_id
+FROM ref_products
+JOIN fk_notenforced_child FOR KEY (ne_p_id) -> ref_products (prod_id)
+ORDER BY ne_id;
+ERROR:  key join from referencing relation fk_notenforced_child to referenced relation ref_products cannot be proven
+LINE 3: JOIN fk_notenforced_child FOR KEY (ne_p_id) -> ref_products ...
+                                  ^
+DETAIL:  There is no matching foreign key constraint for fk_notenforced_child (ne_p_id) referencing ref_products (prod_id).
+-- rejected, reason: NOT VALID FK rejected
+SELECT * FROM ref_products
+JOIN fk_notvalid_child FOR KEY (nv_p_id) -> ref_products (prod_id);
+ERROR:  key join from referencing relation fk_notvalid_child to referenced relation ref_products cannot be proven
+LINE 2: JOIN fk_notvalid_child FOR KEY (nv_p_id) -> ref_products (pr...
+                               ^
+DETAIL:  There is no matching foreign key constraint for fk_notvalid_child (nv_p_id) referencing ref_products (prod_id).
+-- rejected, reason: NOT VALID NOT NULL rejected
+SELECT * FROM ref_products
+JOIN fk_notvalid_notnull_child FOR KEY (nvn_p_id) -> ref_products (prod_id);
+ERROR:  key join from referencing relation fk_notvalid_notnull_child to referenced relation ref_products cannot be proven
+LINE 2: JOIN fk_notvalid_notnull_child FOR KEY (nvn_p_id) -> ref_pro...
+                                       ^
+DETAIL:  This inner join could filter rows from fk_notvalid_notnull_child. Referencing columns fk_notvalid_notnull_child (nvn_p_id) can be null.
+-- accepted, reason: NOT DEFERRABLE key join works even after SET CONSTRAINTS ALL DEFERRED
+BEGIN;
+SET CONSTRAINTS ALL DEFERRED;
+SELECT ref_products.prod_id, fk_orders.ord_id
+FROM ref_products
+-- accepted
+JOIN fk_orders FOR KEY (ord_prod_id) -> ref_products (prod_id)
+ORDER BY ord_id;
+ prod_id | ord_id 
+---------+--------
+       1 |      1
+       1 |      2
+       2 |      3
+(3 rows)
+
+ROLLBACK;
+-- rejected, reason: DEFERRABLE constraint rejected even when currently IMMEDIATE
+SELECT ref_products.prod_id, fk_deferrable_child.df_id
+FROM ref_products
+JOIN fk_deferrable_child FOR KEY (df_p_id) -> ref_products (prod_id)
+ORDER BY df_id;
+ERROR:  key join from referencing relation fk_deferrable_child to referenced relation ref_products cannot be proven
+LINE 3: JOIN fk_deferrable_child FOR KEY (df_p_id) -> ref_products (...
+                                 ^
+DETAIL:  There is no matching foreign key constraint for fk_deferrable_child (df_p_id) referencing ref_products (prod_id).
+-- rejected, reason: stored key join rejected when it depends on a DEFERRABLE constraint
+CREATE VIEW v_deferrable_key_join AS
+SELECT ref_products.prod_id, fk_deferrable_child.df_id
+FROM ref_products
+JOIN fk_deferrable_child FOR KEY (df_p_id) -> ref_products (prod_id);
+ERROR:  key join from referencing relation fk_deferrable_child to referenced relation ref_products cannot be proven
+LINE 4: JOIN fk_deferrable_child FOR KEY (df_p_id) -> ref_products (...
+                                 ^
+DETAIL:  There is no matching foreign key constraint for fk_deferrable_child (df_p_id) referencing ref_products (prod_id).
+-- rejected, reason: prepared key join rejected when it depends on a DEFERRABLE constraint
+PREPARE p_deferrable_key_join AS
+SELECT ref_products.prod_id, fk_deferrable_child.df_id
+FROM ref_products
+JOIN fk_deferrable_child FOR KEY (df_p_id) -> ref_products (prod_id)
+ORDER BY df_id;
+ERROR:  key join from referencing relation fk_deferrable_child to referenced relation ref_products cannot be proven
+LINE 4: JOIN fk_deferrable_child FOR KEY (df_p_id) -> ref_products (...
+                                 ^
+DETAIL:  There is no matching foreign key constraint for fk_deferrable_child (df_p_id) referencing ref_products (prod_id).
+-- rejected, reason: cursor key join rejected when it depends on a DEFERRABLE constraint
+BEGIN;
+DECLARE c_deferrable_key_join CURSOR FOR
+SELECT ref_products.prod_id, fk_deferrable_child.df_id
+FROM ref_products
+-- rejected, reason: cursor query depends on a DEFERRABLE FK
+JOIN fk_deferrable_child FOR KEY (df_p_id) -> ref_products (prod_id)
+ORDER BY df_id;
+ERROR:  key join from referencing relation fk_deferrable_child to referenced relation ref_products cannot be proven
+LINE 5: JOIN fk_deferrable_child FOR KEY (df_p_id) -> ref_products (...
+                                 ^
+DETAIL:  There is no matching foreign key constraint for fk_deferrable_child (df_p_id) referencing ref_products (prod_id).
+ROLLBACK;
+-- rejected, reason: pulled-up subquery key join rejected at PREPARE
+PREPARE p_pulled_up_deferrable_key_join AS
+SELECT q.prod_id, q.df_id
+FROM (
+    SELECT ref_products.prod_id, fk_deferrable_child.df_id
+    FROM ref_products
+    JOIN fk_deferrable_child FOR KEY (df_p_id) -> ref_products (prod_id)
+) q
+ORDER BY df_id;
+ERROR:  key join from referencing relation fk_deferrable_child to referenced relation ref_products cannot be proven
+LINE 6:     JOIN fk_deferrable_child FOR KEY (df_p_id) -> ref_produc...
+                                     ^
+DETAIL:  There is no matching foreign key constraint for fk_deferrable_child (df_p_id) referencing ref_products (prod_id).
+-- rejected, reason: inlined SQL function key join rejected when the body is planned
+PREPARE p_inlined_deferrable_key_join AS
+SELECT * FROM fn_deferrable_key_join()
+ORDER BY df_id;
+EXECUTE p_inlined_deferrable_key_join;
+ERROR:  key join from referencing relation fk_deferrable_child to referenced relation ref_products cannot be proven
+LINE 4:     JOIN fk_deferrable_child FOR KEY (df_p_id) -> ref_produc...
+                                     ^
+DETAIL:  There is no matching foreign key constraint for fk_deferrable_child (df_p_id) referencing ref_products (prod_id).
+QUERY:  
+    SELECT ref_products.prod_id, fk_deferrable_child.df_id
+    FROM ref_products
+    JOIN fk_deferrable_child FOR KEY (df_p_id) -> ref_products (prod_id)
+
+CONTEXT:  SQL function "fn_deferrable_key_join" during inlining
+DEALLOCATE p_inlined_deferrable_key_join;
+-- rejected, reason: DEFERRABLE constraint rejected when currently DEFERRED
+BEGIN;
+SET CONSTRAINTS fk_deferrable_child_fk DEFERRED;
+SELECT * FROM ref_products
+-- rejected, reason: DEFERRABLE FK is currently deferred
+JOIN fk_deferrable_child FOR KEY (df_p_id) -> ref_products (prod_id);
+ERROR:  key join from referencing relation fk_deferrable_child to referenced relation ref_products cannot be proven
+LINE 3: JOIN fk_deferrable_child FOR KEY (df_p_id) -> ref_products (...
+                                 ^
+DETAIL:  There is no matching foreign key constraint for fk_deferrable_child (df_p_id) referencing ref_products (prod_id).
+ROLLBACK;
+-- rejected, reason: INITIALLY DEFERRED constraint rejected by default
+BEGIN;
+SELECT * FROM ref_products
+-- rejected, reason: FK is INITIALLY DEFERRED
+JOIN fk_initially_deferred_child FOR KEY (did_p_id) -> ref_products (prod_id);
+ERROR:  key join from referencing relation fk_initially_deferred_child to referenced relation ref_products cannot be proven
+LINE 3: JOIN fk_initially_deferred_child FOR KEY (did_p_id) -> ref_p...
+                                         ^
+DETAIL:  There is no matching foreign key constraint for fk_initially_deferred_child (did_p_id) referencing ref_products (prod_id).
+ROLLBACK;
+-- rejected, reason: INITIALLY DEFERRED constraint rejected even after SET CONSTRAINTS IMMEDIATE
+BEGIN;
+SET CONSTRAINTS fk_initially_deferred_child_fk IMMEDIATE;
+SELECT ref_products.prod_id, fk_initially_deferred_child.did_id
+FROM ref_products
+-- rejected, reason: INITIALLY DEFERRED FK remains unusable for key joins
+JOIN fk_initially_deferred_child FOR KEY (did_p_id) -> ref_products (prod_id)
+ORDER BY did_id;
+ERROR:  key join from referencing relation fk_initially_deferred_child to referenced relation ref_products cannot be proven
+LINE 4: JOIN fk_initially_deferred_child FOR KEY (did_p_id) -> ref_p...
+                                         ^
+DETAIL:  There is no matching foreign key constraint for fk_initially_deferred_child (did_p_id) referencing ref_products (prod_id).
+ROLLBACK;
+-- rejected, reason: DEFERRABLE UNIQUE does not preserve referenced-side uniqueness
+SELECT q.prod_id, fk_reviews.rev_id
+FROM (
+    SELECT ref_products.prod_id
+    FROM ref_products
+    LEFT JOIN fk_deferrable_unique_inventory
+        FOR KEY (dui_prod_id) -> ref_products (prod_id)
+) q
+JOIN fk_reviews FOR KEY (rev_prod_id) -> q (prod_id);
+ERROR:  key join from referencing relation fk_reviews to referenced relation q cannot be proven
+LINE 8: JOIN fk_reviews FOR KEY (rev_prod_id) -> q (prod_id);
+                        ^
+DETAIL:  Referenced columns q (prod_id) are not proven unique. A preceding join may duplicate rows from referenced relation q.
+-- ============================================================
+-- 9. Unsupported relation kinds
+-- ============================================================
+-- rejected, reason: function as FK source
+SELECT * FROM ref_products
+JOIN fn_products() AS f FOR KEY (prod_id) -> ref_products (prod_id);
+ERROR:  key join from referencing relation f to referenced relation ref_products cannot be proven
+LINE 2: JOIN fn_products() AS f FOR KEY (prod_id) -> ref_products (p...
+                                ^
+DETAIL:  There is no matching foreign key constraint for f (prod_id) referencing ref_products (prod_id).
+-- rejected, reason: materialized view
+SELECT * FROM mv_products
+JOIN fk_orders FOR KEY (ord_prod_id) -> mv_products (prod_id);
+ERROR:  key join from referencing relation fk_orders to referenced relation mv_products cannot be proven
+LINE 2: JOIN fk_orders FOR KEY (ord_prod_id) -> mv_products (prod_id...
+                       ^
+DETAIL:  There is no matching foreign key constraint for fk_orders (ord_prod_id) referencing mv_products (prod_id).
+-- rejected, reason: UNION ALL in derived table
+SELECT * FROM ref_products
+JOIN (
+    SELECT ord_id, ord_prod_id FROM fk_orders
+    UNION ALL
+    SELECT ord_id, ord_prod_id FROM fk_orders
+) AS u FOR KEY (ord_prod_id) -> ref_products (prod_id);
+ERROR:  key join from referencing relation u to referenced relation ref_products cannot be proven
+LINE 6: ) AS u FOR KEY (ord_prod_id) -> ref_products (prod_id);
+               ^
+DETAIL:  There is no matching foreign key constraint for u (ord_prod_id) referencing ref_products (prod_id).
+-- ============================================================
+-- 10. Deep multi-feature stress test
+-- ============================================================
+-- accepted, reason: deeply-nested query stress-testing key join resolution
+--
+-- The key join analysis must drill through 6 levels to reach the base table:
+--   Level 1: ref_products (base table with PK prod_id)
+--   Level 2: LEFT JOIN with fk_orders (subquery on referencing side, column renamed)
+--            + GROUP BY on base-table PK restoring uniqueness
+--   Level 3: subquery wrapper with column rename
+--   Level 4: subquery wrapper with column rename
+--   Level 5: subquery wrapper with column rename
+--   Level 6: outer key join with fk_reviews through all layers
+--
+SELECT rev.rev_id, deep.p_id, deep.order_count
+FROM (
+    -- Level 5: rename layer 3
+    SELECT l4_id AS p_id, l4_cnt AS order_count
+    FROM (
+        -- Level 4: rename layer 2
+        SELECT l3_id AS l4_id, l3_cnt AS l4_cnt
+        FROM (
+            -- Level 3: rename layer 1
+            SELECT grp_id AS l3_id, grp_cnt AS l3_cnt
+            FROM (
+                -- Level 2: GROUP BY on base-table PK restoring uniqueness
+                SELECT ref_products.prod_id AS grp_id, COUNT(*) AS grp_cnt
+                FROM ref_products                  -- Level 1: base table
+                LEFT JOIN (
+                    -- subquery on referencing side with column rename
+                    SELECT ord_prod_id AS opid FROM fk_orders
+                ) ord_q FOR KEY (opid) -> ref_products (prod_id)
+                GROUP BY ref_products.prod_id
+            ) grp
+        ) l3
+    ) l4
+) deep
+-- Level 6: final key join reaching through all rename layers
+JOIN fk_reviews rev FOR KEY (rev_prod_id) -> deep (p_id)
+ORDER BY rev.rev_id;
+ rev_id | p_id | order_count 
+--------+------+-------------
+     10 |    1 |           2
+     20 |    3 |           1
+(2 rows)
+
+-- accepted, reason: view-with-FK-join resolution chain + nested FK chain with <- direction
+-- FROM: deep -> l2 -> l1 -> v_prods_with_inventory (view with key join inside)
+-- SELECT: scalar subquery with 3-level chain using <- at inner level
+SELECT rev.rev_id, deep.p_id,
+       (SELECT COUNT(*)
+        FROM ref_departments
+        JOIN (
+            fk_employees
+            JOIN ref_teams FOR KEY (team_id) <- fk_employees (emp_team_id)
+        ) FOR KEY (team_dept_id) -> ref_departments (dept_id)
+       ) AS emp_count
+FROM (
+    SELECT l2_id AS p_id FROM (
+        SELECT l1_id AS l2_id FROM (
+            SELECT prod_id AS l1_id FROM v_prods_with_inventory
+        ) l1
+    ) l2
+) deep
+JOIN fk_reviews rev FOR KEY (rev_prod_id) -> deep (p_id)
+ORDER BY rev.rev_id;
+ rev_id | p_id | emp_count 
+--------+------+-----------
+     10 |    1 |         2
+     20 |    3 |         2
+(2 rows)
+
+-- rejected, reason: row-filtering WHERE buried under rename layers (R violated)
+SELECT rev.rev_id, deep.p_id
+FROM (
+    SELECT l3_id AS p_id FROM (
+        SELECT l2_id AS l3_id FROM (
+            SELECT l1_id AS l2_id FROM (
+                SELECT prod_id AS l1_id
+                FROM ref_products
+                WHERE prod_name <> 'Widget'
+            ) l1
+        ) l2
+    ) l3
+) deep
+JOIN fk_reviews rev FOR KEY (rev_prod_id) -> deep (p_id)
+ORDER BY rev.rev_id;
+ERROR:  key join from referencing relation rev to referenced relation deep cannot be proven
+LINE 13: JOIN fk_reviews rev FOR KEY (rev_prod_id) -> deep (p_id)
+                             ^
+DETAIL:  Not every rev (rev_prod_id) value can be proven to have a matching deep row. Referenced relation deep is filtered before this key join.
+-- ============================================================
+-- Cleanup
+-- ============================================================
+DROP VIEW v_prods_with_inventory;
+DROP VIEW v_products_renamed;
+DROP VIEW v_products_filtered;
+DROP FUNCTION fn_products();
+DROP FUNCTION fn_deferrable_key_join();
+DROP MATERIALIZED VIEW mv_products;
+DROP TABLE fko_clear_d;
+DROP TABLE fko_clear_b;
+DROP TABLE fko_clear_c;
+DROP TABLE fko_clear_a;
+DROP TABLE fk_initially_deferred_child;
+DROP TABLE fk_deferrable_child;
+DROP TABLE fk_notenforced_child;
+DROP TABLE fk_notvalid_notnull_child;
+DROP TABLE fk_notvalid_child;
+DROP TABLE fk_deferrable_unique_inventory;
+DROP TABLE fk_inventory;
+DROP TABLE fk_returns;
+DROP TABLE fk_employees;
+DROP TABLE fk_warehouses;
+DROP TABLE fk_reviews;
+DROP TABLE fk_orders;
+DROP TABLE ref_teams;
+DROP TABLE ref_departments;
+DROP TABLE ref_locations;
+DROP TABLE ref_products;
diff --git a/src/test/regress/parallel_schedule b/src/test/regress/parallel_schedule
index 8fa0a6c47fb..d13c407316d 100644
--- a/src/test/regress/parallel_schedule
+++ b/src/test/regress/parallel_schedule
@@ -103,6 +103,11 @@ test: publication subscription
 # select_views depends on create_view
 # ----------
 test: select_views portals_p2 foreign_key dependency guc bitmapops combocid tsearch tsdicts foreign_data window xmlmap functional_deps advisory_lock indirect_toast equivclass stats_rewrite graph_table
+test: key_join
+test: key_join_matched_filter
+test: key_join_overview
+test: key_join_mcdc
+test: key_join_icu
 
 # ----------
 # Another group of parallel tests (JSON related)
diff --git a/src/test/regress/sql/collate.icu.utf8.sql b/src/test/regress/sql/collate.icu.utf8.sql
index 18c47e6e05a..171332edd01 100644
--- a/src/test/regress/sql/collate.icu.utf8.sql
+++ b/src/test/regress/sql/collate.icu.utf8.sql
@@ -997,6 +997,18 @@ SELECT * FROM test12pk;
 SELECT * FROM test12fk;  -- was updated
 DROP TABLE test12pk, test12fk;
 
+-- key join proof rejects nondeterministic collations.  PostgreSQL accepts the
+-- FK, but key join does not use nondeterministic equality as proof evidence.
+CREATE TABLE keyjoin_nondet_pk (x text COLLATE case_insensitive PRIMARY KEY);
+CREATE TABLE keyjoin_nondet_fk
+  (a int PRIMARY KEY, b text COLLATE case_insensitive NOT NULL REFERENCES keyjoin_nondet_pk (x));
+INSERT INTO keyjoin_nondet_pk VALUES ('abc');
+INSERT INTO keyjoin_nondet_fk VALUES (1, 'ABC');
+SELECT *
+FROM keyjoin_nondet_pk p
+JOIN keyjoin_nondet_fk f FOR KEY (b) -> p (x);
+DROP TABLE keyjoin_nondet_fk, keyjoin_nondet_pk;
+
 -- partitioning
 CREATE TABLE test20 (a int, b text COLLATE case_insensitive) PARTITION BY LIST (b);
 CREATE TABLE test20_1 PARTITION OF test20 FOR VALUES IN ('abc');
diff --git a/src/test/regress/sql/key_join.sql b/src/test/regress/sql/key_join.sql
new file mode 100644
index 00000000000..7e67700653e
--- /dev/null
+++ b/src/test/regress/sql/key_join.sql
@@ -0,0 +1,5668 @@
+--
+-- Test Foreign Key Joins.
+--
+
+CREATE TABLE t1
+(
+    c1 int not null,
+    c2 int not null,
+    CONSTRAINT t1_pkey PRIMARY KEY (c1)
+);
+
+CREATE TABLE t2
+(
+    c3 int not null,
+    c4 int not null,
+    CONSTRAINT t2_pkey PRIMARY KEY (c3),
+    CONSTRAINT t2_c3_fkey FOREIGN KEY (c3) REFERENCES t1 (c1)
+);
+
+INSERT INTO t1 (c1, c2) VALUES (1, 10);
+INSERT INTO t1 (c1, c2) VALUES (2, 20);
+INSERT INTO t1 (c1, c2) VALUES (3, 30);
+INSERT INTO t2 (c3, c4) VALUES (1, 10);
+INSERT INTO t2 (c3, c4) VALUES (3, 30);
+
+--
+-- Test renaming tables and columns.
+--
+CREATE VIEW v1 AS
+SELECT *
+FROM t1
+-- accepted
+JOIN t2 FOR KEY (c3) -> t1 (c1);
+\d+ v1
+SELECT * FROM v1; -- accepted
+
+ALTER TABLE t1 RENAME COLUMN c1 TO c1_renamed;
+ALTER TABLE t2 RENAME COLUMN c3 TO c3_renamed;
+ALTER TABLE t1 RENAME TO t1_renamed;
+ALTER TABLE t2 RENAME TO t2_renamed;
+\d+ v1
+
+SELECT * FROM v1; -- accepted
+
+-- Undo the effect of the renames
+ALTER TABLE t2_renamed RENAME TO t2;
+ALTER TABLE t1_renamed RENAME TO t1;
+ALTER TABLE t2 RENAME COLUMN c3_renamed TO c3;
+ALTER TABLE t1 RENAME COLUMN c1_renamed TO c1;
+\d+ v1
+
+-- Test so we didn't break the parser
+SELECT 1<-2; -- accepted, result: false
+
+SELECT * FROM v1; -- accepted
+
+SELECT * FROM t1 JOIN t2 FOR KEY (c3) -> t1 (c1); -- accepted
+SELECT * FROM t1 JOIN t2 FOR KEY (c3) ->/*comment*/ t1 (c1); -- accepted
+SELECT * FROM t1 JOIN t2 FOR KEY (c3) /*comment*/-> t1 (c1); -- accepted
+SELECT * FROM t1 JOIN t2 FOR KEY (c3) /*comment*/->/*comment*/ t1 (c1); -- accepted
+SELECT * FROM t1 JOIN t2 FOR KEY (c3) -> t2 (c1); -- rejected, reason: arrow alias must name the left operand
+SELECT * FROM t1 JOIN t2 FOR KEY (c3) - > t1 (c2); -- rejected, reason: -> must be a single token
+SELECT * FROM t1 JOIN t2 FOR KEY (c3) -/*comment*/> t1 (c2); -- rejected, reason: comments cannot split the -> token
+SELECT * FROM t1 JOIN t2 FOR KEY (c3) -> t1 (c2); -- rejected, reason: referenced column is not the FK target key
+SELECT * FROM t1 JOIN t2 FOR KEY (c4) -> t1 (c1); -- rejected, reason: referencing column is not backed by the FK
+SELECT * FROM t1 JOIN t2 FOR KEY (c3,c4) -> t1 (c1,c2); -- rejected, reason: selected key does not match the FK arity
+SELECT * FROM t1 JOIN t2 FOR KEY (c3) <- t1 (c1); -- rejected, reason: wrong direction for this table order
+SELECT * FROM t1 JOIN t2 FOR KEY (c1) <- t1 (c3); -- rejected, reason: wrong direction and wrong referencing column
+SELECT * FROM t1 JOIN t2 FOR KEY (c3) <- t1 (c2); -- rejected, reason: wrong direction and wrong referenced column
+SELECT * FROM t1 JOIN t2 FOR KEY (c4) <- t1 (c1); -- rejected, reason: wrong direction and wrong referencing column
+SELECT * FROM t1 JOIN t2 FOR KEY (c3,c4) <- t1 (c1,c2); -- rejected, reason: wrong direction for the composite key
+SELECT * FROM t1 AS a JOIN t2 AS b FOR KEY (c3) -> a (c2); -- rejected, reason: alias resolves to a non-referenced key column
+
+SELECT * FROM t2 JOIN t1 FOR KEY (c1) <- t2 (c3); -- accepted
+SELECT * FROM t2 JOIN t1 FOR KEY (c1) <-/*comment*/ t2 (c3); -- accepted
+SELECT * FROM t2 JOIN t1 FOR KEY (c1) /*comment*/<- t2 (c3); -- accepted
+SELECT * FROM t2 JOIN t1 FOR KEY (c1) /*comment*/<-/*comment*/ t2 (c3); -- accepted
+SELECT * FROM t2 JOIN t1 FOR KEY (c1) <- t1 (c3); -- rejected, reason: arrow alias must name the left operand
+SELECT * FROM t2 JOIN t1 FOR KEY (c1) < - t2 (c3); -- accepted
+SELECT * FROM t2 JOIN t1 FOR KEY (c1) </*comment*/- t2 (c3); -- accepted
+SELECT * FROM t2 JOIN t1 FOR KEY (c1) <- t2 (c4); -- rejected, reason: referencing column is not backed by the FK
+SELECT * FROM t2 JOIN t1 FOR KEY (c2) <- t2 (c3); -- rejected, reason: referenced column is not the FK target key
+SELECT * FROM t2 JOIN t1 FOR KEY (c1,c2) <- t2 (c3,c4); -- rejected, reason: selected key does not match the FK arity
+SELECT * FROM t2 JOIN t1 FOR KEY (c1) -> t2 (c3); -- rejected, reason: wrong direction for this table order
+SELECT * FROM t2 JOIN t1 FOR KEY (c1) -> t2 (c4); -- rejected, reason: wrong direction and wrong referencing column
+SELECT * FROM t2 JOIN t1 FOR KEY (c2) -> t2 (c3); -- rejected, reason: wrong direction and wrong referenced column
+SELECT * FROM t2 JOIN t1 FOR KEY (c1,c2) -> t2 (c3,c4); -- rejected, reason: wrong direction for the composite key
+SELECT * FROM t2 AS a JOIN t1 AS b FOR KEY (c1) <- a (c4); -- rejected, reason: alias resolves to a non-referencing FK column
+
+-- Self-join elimination must update stored key-join RT indexes when the
+-- duplicate relation is the referencing side.
+SET enable_self_join_elimination = on;
+SELECT c1.c3 AS child_key, p.c1 AS parent_key
+FROM t1 p
+JOIN t2 c1 FOR KEY (c3) -> p (c1)
+JOIN t2 c2 ON c2.c3 = c1.c3
+ORDER BY c1.c3;
+RESET enable_self_join_elimination;
+
+SELECT *
+FROM t1 p
+JOIN LATERAL (SELECT p.c1 AS c1) q
+  FOR KEY (c1) -> p (c1); -- rejected, reason: lateral outer Vars are not direct key facts
+
+SELECT *
+FROM t1 outerp
+JOIN LATERAL (
+    SELECT *
+    FROM t1 p
+    JOIN t2 c2
+        FOR KEY (c3) -> p (c1) FILTER (WHERE outerp.c1 = 1)
+    JOIN t2 c3
+        FOR KEY (c3) -> p (c1)
+) q ON true; -- rejected, reason: lateral filter outer Vars are not direct key facts
+
+SELECT *
+FROM pg_catalog.pg_class pc
+-- rejected, reason: catalog NOT NULL columns do not provide proof facts
+JOIN t2 FOR KEY (c3) -> pc (relname);
+
+CREATE TABLE no_key_join_fact_parent (id int);
+CREATE TABLE no_key_join_fact_child (parent_id int);
+SELECT *
+FROM ONLY no_key_join_fact_parent p
+JOIN ONLY no_key_join_fact_child c
+  FOR KEY (parent_id) -> p (id); -- rejected, reason: ONLY scans have no proof facts here
+DROP TABLE no_key_join_fact_child, no_key_join_fact_parent;
+
+CREATE TABLE invalid_unique_parent (id int);
+CREATE TABLE invalid_unique_child (parent_id int);
+INSERT INTO invalid_unique_parent VALUES (1), (1);
+INSERT INTO invalid_unique_child VALUES (1);
+CREATE UNIQUE INDEX CONCURRENTLY invalid_unique_parent_id_idx
+    ON invalid_unique_parent (id);
+SELECT *
+FROM invalid_unique_parent p
+-- rejected, reason: invalid unique indexes do not provide proof facts
+JOIN invalid_unique_child c FOR KEY (parent_id) -> p (id);
+DROP TABLE invalid_unique_child, invalid_unique_parent;
+
+CREATE TABLE expr_unique_parent (id int);
+CREATE TABLE expr_unique_child (parent_id int);
+CREATE UNIQUE INDEX expr_unique_parent_idx ON expr_unique_parent ((id + 0));
+INSERT INTO expr_unique_parent VALUES (1);
+INSERT INTO expr_unique_child VALUES (1);
+SELECT *
+FROM expr_unique_parent p
+-- rejected, reason: expression indexes do not provide proof facts
+JOIN expr_unique_child c FOR KEY (parent_id) -> p (id);
+DROP TABLE expr_unique_child, expr_unique_parent;
+
+CREATE TABLE partial_unique_parent (id int);
+CREATE TABLE partial_unique_child (parent_id int);
+CREATE UNIQUE INDEX partial_unique_parent_idx ON partial_unique_parent (id)
+    WHERE id > 0;
+INSERT INTO partial_unique_parent VALUES (1);
+INSERT INTO partial_unique_child VALUES (1);
+SELECT *
+FROM partial_unique_parent p
+-- rejected, reason: partial indexes do not provide proof facts
+JOIN partial_unique_child c FOR KEY (parent_id) -> p (id);
+DROP TABLE partial_unique_child, partial_unique_parent;
+
+CREATE TABLE collate_unique_parent (id text COLLATE "C");
+CREATE TABLE collate_unique_child (parent_id text COLLATE "C");
+CREATE UNIQUE INDEX collate_unique_parent_idx
+    ON collate_unique_parent (id COLLATE "POSIX");
+INSERT INTO collate_unique_parent VALUES ('1');
+INSERT INTO collate_unique_child VALUES ('1');
+SELECT *
+FROM collate_unique_parent p
+-- rejected, reason: mismatched index collations do not provide proof facts
+JOIN collate_unique_child c FOR KEY (parent_id) -> p (id);
+DROP TABLE collate_unique_child, collate_unique_parent;
+
+CREATE OPERATOR CLASS noeq_unique_int4_ops FOR TYPE int USING btree AS
+    FUNCTION 1 btint4cmp(int, int);
+CREATE TABLE noeq_unique_parent (id int PRIMARY KEY);
+CREATE TABLE noeq_unique_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES noeq_unique_parent (id)
+);
+CREATE UNIQUE INDEX noeq_unique_parent_bad_idx
+    ON noeq_unique_parent USING btree (id noeq_unique_int4_ops);
+INSERT INTO noeq_unique_parent VALUES (1);
+INSERT INTO noeq_unique_child VALUES (10, 1);
+PREPARE noeq_unique_plan AS
+SELECT *
+FROM noeq_unique_parent p
+-- accepted, reason: unique index without equality operator is skipped
+JOIN noeq_unique_child c FOR KEY (parent_id) -> p (id);
+DEALLOCATE noeq_unique_plan;
+DROP TABLE noeq_unique_child, noeq_unique_parent;
+DROP OPERATOR CLASS noeq_unique_int4_ops USING btree;
+DROP OPERATOR FAMILY noeq_unique_int4_ops USING btree;
+
+CREATE DOMAIN domain_key_join_int AS int;
+CREATE TABLE domain_key_join_parent (id domain_key_join_int PRIMARY KEY);
+CREATE TABLE domain_key_join_child
+(
+    id int PRIMARY KEY,
+    parent_id domain_key_join_int NOT NULL REFERENCES domain_key_join_parent (id)
+);
+INSERT INTO domain_key_join_parent VALUES (1);
+INSERT INTO domain_key_join_child VALUES (1, 1);
+SELECT *
+FROM domain_key_join_parent p
+-- accepted, reason: FK and PK expose the same domain type
+JOIN domain_key_join_child c FOR KEY (parent_id) -> p (id);
+DROP TABLE domain_key_join_child, domain_key_join_parent;
+
+CREATE TABLE domain_key_join_base_parent (id int PRIMARY KEY);
+CREATE TABLE domain_key_join_domain_child
+(
+    id int PRIMARY KEY,
+    parent_id domain_key_join_int NOT NULL
+        REFERENCES domain_key_join_base_parent (id)
+);
+INSERT INTO domain_key_join_base_parent VALUES (1);
+INSERT INTO domain_key_join_domain_child VALUES (1, 1);
+SELECT *
+FROM domain_key_join_base_parent p
+-- rejected, reason: domain FK and base PK do not expose the same type
+JOIN domain_key_join_domain_child c FOR KEY (parent_id) -> p (id);
+DROP TABLE domain_key_join_domain_child, domain_key_join_base_parent;
+
+CREATE TABLE domain_key_join_domain_parent (id domain_key_join_int PRIMARY KEY);
+CREATE TABLE domain_key_join_base_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL
+        REFERENCES domain_key_join_domain_parent (id)
+);
+INSERT INTO domain_key_join_domain_parent VALUES (1);
+INSERT INTO domain_key_join_base_child VALUES (1, 1);
+SELECT *
+FROM domain_key_join_domain_parent p
+-- rejected, reason: base FK and domain PK do not expose the same type
+JOIN domain_key_join_base_child c FOR KEY (parent_id) -> p (id);
+DROP TABLE domain_key_join_base_child, domain_key_join_domain_parent;
+
+DROP DOMAIN domain_key_join_int;
+
+CREATE TABLE key_join_self_syntax
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES key_join_self_syntax (id)
+);
+
+SELECT * FROM key_join_self_syntax AS t1 JOIN key_join_self_syntax AS t2 FOR KEY (parent_id) -> t1 (id); -- accepted
+SELECT * FROM key_join_self_syntax AS t1 JOIN key_join_self_syntax AS t2 FOR KEY (parent_id) -> t2 (id); -- rejected, reason: arrow alias must name the left operand
+SELECT * FROM key_join_self_syntax AS t1 JOIN key_join_self_syntax AS t2 FOR KEY (id) <- t1 (parent_id); -- accepted
+SELECT * FROM key_join_self_syntax AS t1 JOIN key_join_self_syntax AS t2 FOR KEY (id) <- t2 (parent_id); -- rejected, reason: arrow alias must name the left operand
+
+DROP TABLE key_join_self_syntax;
+
+ALTER TABLE t2 DROP CONSTRAINT t2_c3_fkey; -- rejected, reason: stored view depends on the FK proof
+
+DROP VIEW v1;
+ALTER TABLE t2 DROP CONSTRAINT t2_c3_fkey;
+
+/* Recreate contraint and view to test DROP CASCADE */
+ALTER TABLE t2 ADD CONSTRAINT t2_c3_fkey FOREIGN KEY (c3) REFERENCES t1 (c1);
+CREATE VIEW v1 AS
+SELECT *
+FROM t1
+-- accepted
+JOIN t2 FOR KEY (c3) -> t1 (c1);
+ALTER TABLE t2 DROP CONSTRAINT t2_c3_fkey CASCADE; -- accepted
+
+SELECT * FROM t1 JOIN t2 FOR KEY (c3) -> t1 (c1); -- rejected, reason: FK constraint was dropped
+SELECT * FROM t2 JOIN t1 FOR KEY (c1) <- t2 (c3); -- rejected, reason: FK constraint was dropped
+
+ALTER TABLE t1 ADD UNIQUE (c1,c2);
+ALTER TABLE t2 ADD CONSTRAINT t2_c3_c4_fkey FOREIGN KEY (c3,c4) REFERENCES t1 (c1,c2);
+
+CREATE VIEW v2 AS
+SELECT * FROM t1 JOIN t2 FOR KEY (c3,c4) -> t1 (c1,c2); -- accepted
+SELECT * FROM t1 JOIN t2 FOR KEY (c3,c4) -> t1 (c1,c2); -- accepted
+SELECT * FROM v2; -- accepted
+\d+ v2
+
+CREATE VIEW v3 AS
+SELECT * FROM t2 JOIN t1 FOR KEY (c1,c2) <- t2 (c3,c4); -- accepted
+SELECT * FROM t2 JOIN t1 FOR KEY (c1,c2) <- t2 (c3,c4); -- accepted
+\d+ v3
+
+SELECT * FROM v3; -- accepted
+
+SELECT * FROM t1 JOIN t2 FOR KEY (c3) -> t1 (c1); -- rejected, reason: single-column FK proof is no longer available
+SELECT * FROM t2 JOIN t1 FOR KEY (c1) <- t2 (c3); -- rejected, reason: single-column FK proof is no longer available
+SELECT * FROM t1 JOIN t2 FOR KEY (c3,c4) <- t1 (c1,c2); -- rejected, reason: wrong direction for the composite FK
+SELECT * FROM t2 JOIN t1 FOR KEY (c1,c2) -> t2 (c3,c4); -- rejected, reason: wrong direction for the composite FK
+
+--
+-- Referenced-partition FK clone rows do not prove containment in one partition
+--
+
+CREATE TABLE kj_part_parent (id int PRIMARY KEY) PARTITION BY RANGE (id);
+CREATE TABLE kj_part_parent_1 PARTITION OF kj_part_parent
+    FOR VALUES FROM (1) TO (10);
+CREATE TABLE kj_part_parent_2 PARTITION OF kj_part_parent
+    FOR VALUES FROM (10) TO (20);
+CREATE TABLE kj_part_child
+(
+    id  int PRIMARY KEY,
+    pid int NOT NULL REFERENCES kj_part_parent (id)
+);
+INSERT INTO kj_part_parent VALUES (1), (11);
+INSERT INTO kj_part_child VALUES (101, 1), (111, 11);
+
+SELECT c.id AS child_id, c.pid, p.id AS parent_id
+FROM kj_part_parent p
+-- accepted, reason: parent FK proves containment in the whole partitioned table
+JOIN kj_part_child c FOR KEY (pid) -> p (id)
+ORDER BY c.id;
+
+SELECT c.id AS child_id, c.pid, p1.id AS parent_id
+FROM kj_part_parent_1 p1
+-- rejected, reason: a cloned FK to one referenced partition is not a global containment proof
+JOIN kj_part_child c FOR KEY (pid) -> p1 (id)
+ORDER BY c.id;
+
+CREATE VIEW kj_part_bad_view AS
+SELECT c.id AS child_id, c.pid, p1.id AS parent_id
+FROM kj_part_parent_1 p1
+-- rejected, reason: invalid referenced-partition FK proof cannot be stored
+JOIN kj_part_child c FOR KEY (pid) -> p1 (id);
+
+CREATE TABLE kj_part_ref_parent (id int PRIMARY KEY);
+CREATE TABLE kj_part_ref_child
+(
+    id  int,
+    pid int NOT NULL REFERENCES kj_part_ref_parent (id)
+) PARTITION BY RANGE (id);
+CREATE TABLE kj_part_ref_child_1 PARTITION OF kj_part_ref_child
+    FOR VALUES FROM (1) TO (10);
+CREATE TABLE kj_part_ref_child_sub PARTITION OF kj_part_ref_child
+    FOR VALUES FROM (10) TO (20) PARTITION BY RANGE (id);
+CREATE TABLE kj_part_ref_child_sub_1 PARTITION OF kj_part_ref_child_sub
+    FOR VALUES FROM (10) TO (15);
+INSERT INTO kj_part_ref_parent VALUES (1), (11);
+INSERT INTO kj_part_ref_child_1 VALUES (1, 1);
+INSERT INTO kj_part_ref_child_sub_1 VALUES (11, 11);
+
+SELECT c.id AS child_id, c.pid, p.id AS parent_id
+FROM kj_part_ref_parent p
+-- accepted, reason: referencing-side partition FK still targets the same referenced keyspace
+JOIN kj_part_ref_child_1 c FOR KEY (pid) -> p (id);
+
+SELECT c.id AS child_id, c.pid, p.id AS parent_id
+FROM kj_part_ref_parent p
+-- accepted, reason: multi-level referencing partition FK still targets the same referenced keyspace
+JOIN kj_part_ref_child_sub_1 c FOR KEY (pid) -> p (id);
+
+DROP TABLE kj_part_ref_child, kj_part_ref_parent;
+DROP TABLE kj_part_child, kj_part_parent;
+
+--
+-- Test nulls and multiple tables
+--
+
+CREATE TABLE t3
+(
+    c5 int,
+    c6 int,
+    CONSTRAINT t3_c5_c6_fkey FOREIGN KEY (c5, c6) REFERENCES t1 (c1, c2)
+);
+INSERT INTO t3 (c5, c6) VALUES (1, 10); -- accepted
+INSERT INTO t3 (c5, c6) VALUES (3, 30); -- accepted
+INSERT INTO t3 (c5, c6) VALUES (3, NULL); -- accepted
+INSERT INTO t3 (c5, c6) VALUES (NULL, 30); -- accepted
+INSERT INTO t3 (c5, c6) VALUES (1234, NULL); -- accepted
+INSERT INTO t3 (c5, c6) VALUES (NULL, 5678); -- accepted
+INSERT INTO t3 (c5, c6) VALUES (NULL, NULL); -- accepted
+
+--
+-- Test composite key joins with columns in matching order
+--
+SELECT *
+FROM t1
+-- rejected, reason: nullable referencing columns are not preserved by an inner key join
+JOIN t2 FOR KEY (c3,c4) -> t1 (c1,c2)
+JOIN t3 FOR KEY (c5,c6) -> t1 (c1,c2);
+
+SELECT *
+FROM t1
+-- rejected, reason: nullable referencing columns are on the non-preserved side
+JOIN t2 FOR KEY (c3,c4) -> t1 (c1,c2)
+LEFT JOIN t3 FOR KEY (c5,c6) -> t1 (c1,c2);
+
+SELECT *
+FROM t1
+-- rejected, reason: referenced t1 surface is unique but not row-covered after the preceding inner key join
+JOIN t2 FOR KEY (c3,c4) -> t1 (c1,c2)
+RIGHT JOIN t3 FOR KEY (c5,c6) -> t1 (c1,c2);
+
+--
+-- Test composite key joins with swapped column orders
+--
+SELECT *
+FROM t1
+-- rejected, reason: nullable referencing columns are not preserved by an inner key join
+JOIN t2 FOR KEY (c4,c3) -> t1 (c2,c1)
+JOIN t3 FOR KEY (c6,c5) -> t1 (c2,c1);
+
+--
+-- Test mismatched column orders between referencing and referenced sides
+--
+SELECT *
+FROM t1
+-- accepted
+JOIN t2 FOR KEY (c4,c3) -> t1 (c2,c1)
+JOIN t3 FOR KEY (c6,c5) -> t1 (c1,c2); -- rejected, reason: referencing and referenced column orders do not match the FK
+
+--
+-- Test defining foreign key constraints with MATCH FULL
+--
+
+CREATE TABLE t4
+(
+    c7 int,
+    c8 int,
+    CONSTRAINT t4_c7_c8_fkey FOREIGN KEY (c7, c8) REFERENCES t1 (c1, c2) MATCH FULL
+);
+INSERT INTO t4 (c7, c8) VALUES (1, 10); -- accepted
+INSERT INTO t4 (c7, c8) VALUES (3, 30); -- accepted
+INSERT INTO t4 (c7, c8) VALUES (3, NULL); -- rejected, reason: MATCH FULL disallows partially NULL composite FK values
+INSERT INTO t4 (c7, c8) VALUES (NULL, 30); -- rejected, reason: MATCH FULL disallows partially NULL composite FK values
+INSERT INTO t4 (c7, c8) VALUES (1234, NULL); -- rejected, reason: MATCH FULL disallows partially NULL composite FK values
+INSERT INTO t4 (c7, c8) VALUES (NULL, 5678); -- rejected, reason: MATCH FULL disallows partially NULL composite FK values
+INSERT INTO t4 (c7, c8) VALUES (NULL, NULL); -- accepted
+
+SELECT *
+FROM t1
+-- rejected, reason: nullable referencing columns are not preserved by an inner key join
+JOIN t2 FOR KEY (c3,c4) -> t1 (c1,c2)
+JOIN t4 FOR KEY (c7,c8) -> t1 (c1,c2);
+
+SELECT *
+FROM t1
+-- rejected, reason: nullable referencing columns are on the non-preserved side
+JOIN t2 FOR KEY (c3,c4) -> t1 (c1,c2)
+LEFT JOIN t4 FOR KEY (c7,c8) -> t1 (c1,c2);
+
+SELECT *
+FROM t1
+-- rejected, reason: referenced t1 surface is unique but not row-covered after the preceding inner key join
+JOIN t2 FOR KEY (c3,c4) -> t1 (c1,c2)
+RIGHT JOIN t4 FOR KEY (c7,c8) -> t1 (c1,c2);
+
+-- Recreate stuff for pg_dump tests
+ALTER TABLE t2
+    ADD CONSTRAINT t2_c3_fkey FOREIGN KEY (c3) REFERENCES t1 (c1);
+CREATE VIEW v1 AS
+SELECT *
+FROM t1
+-- accepted
+JOIN t2 FOR KEY (c3) -> t1 (c1);
+
+CREATE TABLE t5
+(
+    c9 int not null,
+    c10 int not null,
+    c11 int not null,
+    c12 int not null,
+    CONSTRAINT t5_pkey PRIMARY KEY (c9, c10),
+    CONSTRAINT t5_c11_c12_fkey FOREIGN KEY (c11, c12) REFERENCES t1 (c1, c2)
+);
+
+INSERT INTO t5 (c9, c10, c11, c12) VALUES (1, 2, 1, 10);
+INSERT INTO t5 (c9, c10, c11, c12) VALUES (3, 4, 3, 30);
+
+CREATE TABLE t6
+(
+    c13 int not null,
+    c14 int not null,
+    CONSTRAINT t6_c13_c14_fkey FOREIGN KEY (c13, c14) REFERENCES t5 (c9, c10)
+);
+
+INSERT INTO t6 (c13, c14) VALUES (1, 2);
+INSERT INTO t6 (c13, c14) VALUES (3, 4);
+INSERT INTO t6 (c13, c14) VALUES (3, 4);
+
+CREATE TABLE t7
+(
+    c15 int not null,
+    c16 int not null,
+    CONSTRAINT t7_c15_c16_fkey FOREIGN KEY (c15, c16) REFERENCES t5 (c9, c10)
+);
+
+INSERT INTO t7 (c15, c16) VALUES (1, 2);
+INSERT INTO t7 (c15, c16) VALUES (1, 2);
+INSERT INTO t7 (c15, c16) VALUES (3, 4);
+
+CREATE TABLE t8
+(
+    c17 int not null,
+    c18 int not null,
+    c19 int,
+    c20 int,
+    CONSTRAINT t8_pkey PRIMARY KEY (c17, c18),
+    CONSTRAINT t8_c19_c20_fkey FOREIGN KEY (c19, c20) REFERENCES t1 (c1, c2)
+);
+
+INSERT INTO t8 (c17, c18, c19, c20) VALUES (1, 2, 1, 10);
+INSERT INTO t8 (c17, c18, c19, c20) VALUES (3, 4, 3, 30);
+
+CREATE TABLE t9
+(
+    c21 int not null,
+    c22 int not null,
+    CONSTRAINT t9_c21_c22_fkey FOREIGN KEY (c21, c22) REFERENCES t8 (c17, c18)
+);
+
+INSERT INTO t9 (c21, c22) VALUES (1, 2);
+INSERT INTO t9 (c21, c22) VALUES (3, 4);
+INSERT INTO t9 (c21, c22) VALUES (3, 4);
+
+CREATE TABLE t10
+(
+    c23 INT NOT NULL,
+    c24 INT NOT NULL,
+    c25 INT NOT NULL,
+    c26 INT NOT NULL,
+    CONSTRAINT t10_pkey PRIMARY KEY (c23, c24),
+    CONSTRAINT t10_c23_c24_fkey FOREIGN KEY (c23, c24) REFERENCES t1 (c1, c2),
+    CONSTRAINT t10_c25_c26_fkey FOREIGN KEY (c25, c26) REFERENCES t10 (c23, c24)
+);
+
+INSERT INTO t10 (c23, c24, c25, c26) VALUES (1, 10, 1, 10);
+
+CREATE TABLE t11
+(
+    c27 INT NOT NULL,
+    c28 INT NOT NULL,
+    CONSTRAINT t11_pkey PRIMARY KEY (c27, c28),
+    CONSTRAINT t11_c27_c28_fkey FOREIGN KEY (c27, c28) REFERENCES t10 (c23, c24)
+);
+
+INSERT INTO t11 (c27, c28) VALUES (1, 10);
+
+--
+-- Test subqueries
+--
+
+SELECT
+    a.c1,
+    a.c2,
+    b.c3,
+    b.c4
+FROM t1 AS a
+JOIN
+(
+    SELECT * FROM t2
+-- accepted
+) AS b FOR KEY (c3) -> a (c1);
+
+SELECT
+    a.c1,
+    a.c2,
+    b.c3,
+    b.c4
+FROM
+(
+    SELECT * FROM t1
+) AS a
+JOIN
+(
+    SELECT * FROM t2
+-- accepted
+) AS b FOR KEY (c3) -> a (c1);
+
+SELECT
+    a.t1_c1,
+    a.t1_c2,
+    b.t2_c3,
+    b.t2_c4
+FROM
+(
+    SELECT c1 AS t1_c1, c2 AS t1_c2 FROM t1
+) AS a
+JOIN
+(
+    SELECT c3 AS t2_c3, c4 AS t2_c4 FROM t2
+-- accepted
+) AS b FOR KEY (t2_c3) -> a (t1_c1);
+
+SELECT
+    a.outer_c1,
+    a.outer_c2,
+    b.outer_c3,
+    b.outer_c4
+FROM
+(
+    SELECT mid_c1 AS outer_c1, mid_c2 AS outer_c2 FROM
+    (
+        SELECT c1 AS mid_c1, c2 AS mid_c2 FROM t1
+    ) sub1
+) AS a
+JOIN
+(
+    SELECT mid_c3 AS outer_c3, mid_c4 AS outer_c4 FROM
+    (
+        SELECT c3 AS mid_c3, c4 AS mid_c4 FROM t2
+    ) sub2
+-- accepted
+) AS b FOR KEY (outer_c3) -> a (outer_c1);
+
+SELECT *
+FROM t1
+JOIN
+(
+    SELECT
+        t10.c23,
+        t10.c24,
+        t10_2.c25,
+        t10_2.c26
+    FROM t10
+-- accepted
+    JOIN t10 AS t10_2 FOR KEY (c23, c24) <- t10 (c25, c26)
+) AS q1 FOR KEY (c23, c24) -> t1 (c1, c2);
+
+SELECT *
+FROM t1
+JOIN LATERAL (
+    SELECT c3, c4 FROM t2 WHERE c4 = c1 + 9
+-- rejected, reason: LATERAL subqueries do not expose surface FK facts
+) AS q1 FOR KEY (c3) -> t1 (c1);
+
+SELECT *
+FROM t1 AS outer_t,
+     LATERAL (SELECT outer_t.c1) AS q1(c1)
+-- rejected, reason: LATERAL subqueries do not expose referenced-side surface facts
+     JOIN t2 FOR KEY (c3) -> q1 (c1);
+
+SELECT *
+FROM t1 AS outer_t,
+     LATERAL (SELECT outer_t.c1, g FROM generate_series(1, 2) AS g) AS q1(c1, g)
+-- rejected, reason: LATERAL referenced side can duplicate inherited keys
+     JOIN t2 FOR KEY (c3) -> q1 (c1);
+
+SELECT *
+FROM t1 AS outer_t,
+     LATERAL (SELECT outer_t.c1 WHERE false) AS q1(c1)
+-- rejected, reason: LATERAL referenced side can suppress inherited keys
+     JOIN t2 FOR KEY (c3) -> q1 (c1);
+
+--
+-- Test CTEs
+--
+
+WITH
+q1 (q1_c1, q1_c2) AS
+(
+    SELECT c1, c2 FROM t1
+),
+q2 (q2_c1, q2_c2) AS
+(
+    SELECT q1_c1, q1_c2 FROM q1
+),
+q3 (q3_c3, q3_c4) AS
+(
+    SELECT c3, c4 FROM t2
+),
+q4 (q4_c3, q4_c4) AS
+(
+    SELECT q3_c3, q3_c4 FROM q3
+)
+SELECT
+    q2_c1,
+    q2_c2,
+    q4_c3,
+    q4_c4
+-- accepted
+FROM q2 JOIN q4 FOR KEY (q4_c3, q4_c4) -> q2 (q2_c1, q2_c2);
+
+WITH RECURSIVE q1 AS (SELECT c1 FROM t1 UNION SELECT c1 FROM q1)
+-- rejected, reason: recursive CTEs do not expose surface FK facts
+SELECT * FROM q1 JOIN t2 FOR KEY (c3) -> q1 (c1);
+
+WITH RECURSIVE rec_self(c1) AS (
+    SELECT c1 FROM t1
+    UNION ALL
+    SELECT t2.c3
+    FROM rec_self
+    -- rejected, reason: recursive self-references do not expose surface FK facts
+    JOIN t2 FOR KEY (c3) -> rec_self (c1)
+    WHERE false
+)
+SELECT * FROM rec_self;
+
+-- Regression: a FOR KEY join inside a CTE body of a WITH RECURSIVE
+-- block must not raise "unrecognized node type" from the raw
+-- parse-tree walker that builds the CTE dependency graph.
+WITH RECURSIVE rec_kj AS (
+    SELECT t2.c3 FROM t1 JOIN t2 FOR KEY (c3) -> t1 (c1)
+    UNION ALL
+    SELECT c3 FROM rec_kj WHERE false
+)
+-- rejected, reason: recursive CTEs do not expose surface FK facts
+SELECT * FROM rec_kj JOIN t2 t2b FOR KEY (c3) -> rec_kj (c3);
+
+-- Regression: same as above, but the FOR KEY join is in a sibling CTE
+-- of the recursive one.  makeDependencyGraph walks every CTE body in
+-- the WITH block, so the raw walker must handle KeyJoinClause here too.
+WITH RECURSIVE rec_only AS (
+    SELECT 1 AS x UNION ALL SELECT x FROM rec_only WHERE false
+), kj_sibling AS (
+    SELECT t1.c1 FROM t1 JOIN t2 FOR KEY (c3) -> t1 (c1)
+)
+SELECT * FROM rec_only, kj_sibling;
+
+-- Regression: recursive CTE validation must inspect a FOR KEY FILTER
+-- before parse analysis merges it into the transformed join quals.
+WITH RECURSIVE rec_filter(n) AS (
+    SELECT 1
+    UNION ALL
+    SELECT rec_filter.n + 1
+    FROM rec_filter
+    CROSS JOIN (
+        t1 JOIN t2 FOR KEY (c3) -> t1 (c1)
+            FILTER (WHERE EXISTS (SELECT 1 FROM rec_filter r2))
+    ) kj
+    WHERE rec_filter.n < 3
+)
+SELECT * FROM rec_filter;
+
+--
+-- Test VIEWs
+--
+
+DROP VIEW v1, v2, v3;
+
+CREATE VIEW v1 AS
+SELECT c1 AS v1_c1, c2 AS v1_c2 FROM t1;
+
+CREATE VIEW v2 AS
+SELECT v1_c1 AS v2_c1, v1_c2 AS v2_c2 FROM v1;
+
+CREATE VIEW v3 AS
+SELECT c3 AS v3_c3, c4 AS v3_c4 FROM t2;
+
+CREATE VIEW v4 AS
+SELECT v3_c3 AS v4_c3, v3_c4 AS v4_c4 FROM v3;
+
+CREATE VIEW v5 AS
+SELECT
+    v2_c1,
+    v2_c2,
+    v4_c3,
+    v4_c4
+-- accepted
+FROM v2 JOIN v4 FOR KEY (v4_c3, v4_c4) -> v2 (v2_c1, v2_c2);
+
+SELECT * FROM v5;
+
+--
+-- Test subqueries, CTEs, and views
+--
+WITH
+q2 (q2_c1, q2_c2) AS
+(
+    SELECT
+        q1_c1,
+        q1_c2
+    FROM
+    (
+        SELECT c1 AS q1_c1, c2 AS q1_c2 FROM t1
+    ) AS q1
+)
+SELECT
+    q2_c1,
+    q2_c2,
+    v4_c3,
+    v4_c4
+-- accepted
+FROM q2 JOIN v4 FOR KEY (v4_c3, v4_c4) -> q2 (q2_c1, q2_c2);
+
+DROP VIEW v1, v2, v3, v4, v5;
+
+--
+-- Test subqueries, CTEs and VIEWs containing joins
+--
+
+SELECT
+    q1.c11,
+    q1.c12,
+    t6.c13,
+    t6.c14
+FROM
+(
+    SELECT
+        t5.c9,
+        t5.c10,
+        t5.c11,
+        t5.c12
+    FROM t5
+-- accepted
+    JOIN t1 FOR KEY (c1, c2) <- t5 (c11, c12)
+    JOIN t1 AS t1_2 FOR KEY (c1, c2) <- t5 (c11, c12)
+    JOIN t1 AS t1_3 FOR KEY (c1, c2) <- t5 (c11, c12)
+) AS q1
+JOIN t6 FOR KEY (c13, c14) -> q1 (c9, c10);
+
+WITH
+q1 AS
+(
+    SELECT
+        t5.c9,
+        t5.c10,
+        t5.c11,
+        t5.c12
+    FROM t5
+-- accepted
+    JOIN t1 FOR KEY (c1, c2) <- t5 (c11, c12)
+    JOIN t1 AS t1_2 FOR KEY (c1, c2) <- t5 (c11, c12)
+    JOIN t1 AS t1_3 FOR KEY (c1, c2) <- t5 (c11, c12)
+)
+SELECT
+    q1.c11,
+    q1.c12,
+    t6.c13,
+    t6.c14
+FROM q1
+JOIN t6 FOR KEY (c13, c14) -> q1 (c9, c10);
+
+CREATE VIEW v1 AS
+SELECT
+    t5.c9,
+    t5.c10,
+    t5.c11,
+    t5.c12
+FROM t5
+-- accepted
+JOIN t1 FOR KEY (c1, c2) <- t5 (c11, c12)
+JOIN t1 AS t1_2 FOR KEY (c1, c2) <- t5 (c11, c12)
+JOIN t1 AS t1_3 FOR KEY (c1, c2) <- t5 (c11, c12);
+
+SELECT
+    v1.c11,
+    v1.c12,
+    t6.c13,
+    t6.c14
+FROM v1
+-- accepted
+JOIN t6 FOR KEY (c13, c14) -> v1 (c9, c10);
+
+DROP VIEW v1;
+
+--
+-- Test disallowed filtering of referenced table
+--
+
+CREATE VIEW v1 AS
+SELECT * FROM t1 WHERE c1 > 0;
+
+CREATE VIEW v2 AS
+SELECT * FROM t2 WHERE c3 > 0;
+
+-- rejected, reason: v1 is filtered (referenced) but t2 (referencing) applies no
+-- equivalent filter — the matched-filter rule cannot rescue this
+SELECT * FROM v1 JOIN t2 FOR KEY (c3) -> v1 (c1);
+
+-- accepted, filtering allowed since v2 is the referencing table
+SELECT * FROM t1 JOIN v2 FOR KEY (c3) -> t1 (c1);
+
+-- rejected, reason: matching inequalities are not accepted by the conservative
+-- matched-filter rule, even when the expression trees line up under the FK
+-- column mapping.
+SELECT * FROM v1 JOIN v2 FOR KEY (c3) -> v1 (c1);
+
+-- rejected, reason: referenced-side HAVING filter is not a matched key filter
+SELECT * FROM
+(
+    SELECT c1, count(*) FROM t1 GROUP BY c1 HAVING c2 > 100
+) AS u
+JOIN t2 FOR KEY (c3) -> u (c1);
+
+-- rejected, reason: u is filtered and is the referenced table
+SELECT * FROM (SELECT c1 FROM t1 LIMIT 1) AS u
+JOIN t2 FOR KEY (c3) -> u (c1);
+
+-- rejected, reason: u is filtered and is the referenced table
+SELECT * FROM (SELECT c1 FROM t1 OFFSET 1) AS u
+JOIN t2 FOR KEY (c3) -> u (c1);
+
+WITH q2 AS
+(
+    SELECT * FROM t5 WHERE t5.c11 > 0
+)
+SELECT
+    q1.c11,
+    q1.c12,
+    t7.c15,
+    t7.c16
+FROM
+(
+    SELECT
+        q2.c9,
+        q2.c10,
+        q2.c11,
+        q2.c12
+    FROM q2
+-- rejected, reason: referenced-side q1 is filtered on a non-key column for c9,c10 coverage
+    JOIN t1 FOR KEY (c1, c2) <- q2 (c11, c12)
+) AS q1
+JOIN t7 FOR KEY (c15, c16) -> q1 (c9, c10);
+
+--
+-- Test allowed joins not affecting uniqueness
+--
+
+SELECT
+    q1.c11,
+    q1.c12,
+    t6.c13,
+    t6.c14
+FROM
+(
+    SELECT
+        t5.c9,
+        t5.c10,
+        t5.c11,
+        t5.c12
+    FROM t5
+-- accepted
+    JOIN t1 FOR KEY (c1, c2) <- t5 (c11, c12)
+) AS q1
+JOIN t6 FOR KEY (c13, c14) -> q1 (c9, c10);
+
+--
+-- Test disallowed non-unique referenced table
+--
+
+SELECT
+    q1.c11,
+    q1.c12,
+    t7.c15,
+    t7.c16
+FROM
+(
+    SELECT
+        t5.c9,
+        t5.c10,
+        t5.c11,
+        t5.c12
+    FROM t5
+-- accepted
+    JOIN t1 FOR KEY (c1, c2) <- t5 (c11, c12)
+    JOIN t6 FOR KEY (c13, c14) -> t5 (c9, c10)
+) AS q1
+-- rejected, reason: inner key join duplicates the referenced key
+JOIN t7 FOR KEY (c15, c16) -> q1 (c9, c10);
+
+--
+-- Test revalidation of views
+--
+
+CREATE TABLE addresses
+(
+    id           INTEGER      NOT NULL,
+    street       VARCHAR(255) NOT NULL,
+    city         VARCHAR(100) NOT NULL,
+    state        VARCHAR(100) NOT NULL,
+    country_code CHAR(2)      NOT NULL,
+    zip_code     VARCHAR(20)  NOT NULL,
+    CONSTRAINT addresses_pkey PRIMARY KEY (id)
+);
+
+CREATE TABLE customers
+(
+    id         INTEGER      NOT NULL,
+    name       VARCHAR(255) NOT NULL,
+    address_id INTEGER      NOT NULL,
+    CONSTRAINT customers_pkey            PRIMARY KEY (id),
+    CONSTRAINT customers_address_id_fkey FOREIGN KEY (address_id) REFERENCES addresses (id)
+);
+
+CREATE TABLE orders
+(
+    id           BIGINT         NOT NULL,
+    order_date   DATE           NOT NULL,
+    amount       DECIMAL(10, 2) NOT NULL,
+    customer_id  INTEGER        NOT NULL,
+    CONSTRAINT orders_pkey             PRIMARY KEY (id),
+    CONSTRAINT orders_customer_id_fkey FOREIGN KEY (customer_id) REFERENCES customers (id)
+);
+
+CREATE VIEW customer_details AS
+SELECT
+    c.id AS customer_id,
+    c.name AS customer_name,
+    a.street,
+    a.city,
+    a.state,
+    a.country_code,
+    a.zip_code
+FROM customers AS c
+-- accepted
+JOIN addresses AS a FOR KEY (id) <- c (address_id);
+
+CREATE VIEW orders_by_country AS
+SELECT
+    cd.country_code,
+    COUNT(*) AS order_count,
+    SUM(o.amount) AS total_amount
+FROM orders AS o
+-- accepted
+JOIN customer_details AS cd FOR KEY (customer_id) <- o (customer_id)
+GROUP BY ROLLUP (cd.country_code);
+
+-- Test NOT NULL constraint dependency: should error because customer_details
+-- (and transitively orders_by_country) depends on address_id being NOT NULL
+ALTER TABLE customers ALTER COLUMN address_id DROP NOT NULL;
+
+-- Replace customer_details with LEFT JOIN version.  Revalidating
+-- orders_by_country only shrinks its proof dependencies, so the replacement
+-- can proceed without rewriting orders_by_country.
+CREATE OR REPLACE VIEW customer_details AS
+SELECT
+    c.id AS customer_id,
+    c.name AS customer_name,
+    a.street,
+    a.city,
+    a.state,
+    a.country_code,
+    a.zip_code
+FROM customers AS c
+-- accepted
+LEFT JOIN addresses AS a FOR KEY (id) <- c (address_id);
+
+-- DROP NOT NULL succeeds because recreating customer_details as a LEFT JOIN
+-- removed its stored proof dependency on customers.address_id NOT NULL.
+-- orders_by_country was revalidated against the new producer surface; its
+-- key join needs customer_id uniqueness and row coverage, not that internal
+-- NOT NULL proof.
+ALTER TABLE customers ALTER COLUMN address_id DROP NOT NULL;
+
+-- Restore NOT NULL and the inner-join producer for subsequent tests.
+DROP VIEW orders_by_country;
+ALTER TABLE customers ALTER COLUMN address_id SET NOT NULL;
+
+CREATE OR REPLACE VIEW customer_details AS
+SELECT
+    c.id AS customer_id,
+    c.name AS customer_name,
+    a.street,
+    a.city,
+    a.state,
+    a.country_code,
+    a.zip_code
+FROM customers AS c
+-- accepted
+JOIN addresses AS a FOR KEY (id) <- c (address_id);
+
+CREATE VIEW orders_by_country AS
+SELECT
+    cd.country_code,
+    COUNT(*) AS order_count,
+    SUM(o.amount) AS total_amount
+FROM orders AS o
+-- accepted
+JOIN customer_details AS cd FOR KEY (customer_id) <- o (customer_id)
+GROUP BY ROLLUP (cd.country_code);
+
+--
+-- Test NOT NULL constraint tracking for various join types
+-- Create simple test tables for these tests
+--
+CREATE TABLE t1_nn (t1_id INTEGER PRIMARY KEY);
+CREATE TABLE t2_nn (
+    t2_id INTEGER PRIMARY KEY,
+    t2_t1_id_nn INTEGER NOT NULL REFERENCES t1_nn(t1_id),
+    t2_t1_id_nullable INTEGER REFERENCES t1_nn(t1_id)
+);
+
+INSERT INTO t1_nn VALUES (1), (2), (3);
+INSERT INTO t2_nn VALUES (10, 1, 1), (20, 2, NULL);
+
+-- Test 1: LEFT JOIN where referencing is on inner side (right)
+-- accepted
+CREATE VIEW v_left_ref_inner AS
+SELECT * FROM t1_nn LEFT JOIN t2_nn FOR KEY (t2_t1_id_nn) -> t1_nn (t1_id);
+ALTER TABLE t2_nn ALTER COLUMN t2_t1_id_nn DROP NOT NULL; -- rejected, reason: view depends on the NOT NULL proof
+DROP VIEW v_left_ref_inner;
+
+-- Test 2: LEFT JOIN where referencing is on outer side (left)
+-- Producer facts alone do not create stored dependencies.
+CREATE VIEW v_left_ref_outer AS
+-- accepted
+SELECT * FROM t2_nn LEFT JOIN t1_nn FOR KEY (t1_id) <- t2_nn (t2_t1_id_nn);
+ALTER TABLE t2_nn ALTER COLUMN t2_t1_id_nn DROP NOT NULL; -- accepted, reason: no stored proof consumes the NOT NULL fact
+ALTER TABLE t2_nn ALTER COLUMN t2_t1_id_nn SET NOT NULL;
+DROP VIEW v_left_ref_outer;
+
+-- Test 3: RIGHT JOIN where referencing is on inner side (left)
+-- accepted
+CREATE VIEW v_right_ref_inner AS
+SELECT * FROM t2_nn RIGHT JOIN t1_nn FOR KEY (t1_id) <- t2_nn (t2_t1_id_nn);
+ALTER TABLE t2_nn ALTER COLUMN t2_t1_id_nn DROP NOT NULL; -- rejected, reason: view depends on the NOT NULL proof
+DROP VIEW v_right_ref_inner;
+
+-- Test 4: RIGHT JOIN where referencing is on outer side (right)
+-- Producer facts alone do not create stored dependencies.
+CREATE VIEW v_right_ref_outer AS
+-- accepted
+SELECT * FROM t1_nn RIGHT JOIN t2_nn FOR KEY (t2_t1_id_nn) -> t1_nn (t1_id);
+ALTER TABLE t2_nn ALTER COLUMN t2_t1_id_nn DROP NOT NULL; -- accepted, reason: no stored proof consumes the NOT NULL fact
+ALTER TABLE t2_nn ALTER COLUMN t2_t1_id_nn SET NOT NULL;
+DROP VIEW v_right_ref_outer;
+
+-- Test 5: FULL JOIN preserves referencing rows, so the key-join proof does
+-- not consume the NOT NULL fact.
+CREATE VIEW v_full_1 AS
+-- accepted
+SELECT * FROM t1_nn FULL JOIN t2_nn FOR KEY (t2_t1_id_nn) -> t1_nn (t1_id);
+ALTER TABLE t2_nn ALTER COLUMN t2_t1_id_nn DROP NOT NULL; -- accepted, reason: no stored proof consumes the NOT NULL fact
+ALTER TABLE t2_nn ALTER COLUMN t2_t1_id_nn SET NOT NULL;
+DROP VIEW v_full_1;
+
+CREATE VIEW v_full_2 AS
+-- accepted
+SELECT * FROM t2_nn FULL JOIN t1_nn FOR KEY (t1_id) <- t2_nn (t2_t1_id_nn);
+ALTER TABLE t2_nn ALTER COLUMN t2_t1_id_nn DROP NOT NULL; -- accepted, reason: no stored proof consumes the NOT NULL fact
+ALTER TABLE t2_nn ALTER COLUMN t2_t1_id_nn SET NOT NULL;
+DROP VIEW v_full_2;
+
+-- A second-layer stored key join that consumes a producer view's not-null
+-- fact activates the underlying NOT NULL dependency.
+CREATE VIEW v_notnull_producer AS
+SELECT t2_id, t2_t1_id_nn FROM t2_nn;
+CREATE VIEW v_notnull_consumer AS
+-- accepted
+SELECT *
+FROM t1_nn JOIN v_notnull_producer p
+    FOR KEY (t2_t1_id_nn) -> t1_nn (t1_id);
+ALTER TABLE t2_nn ALTER COLUMN t2_t1_id_nn DROP NOT NULL; -- rejected, reason: consumer view depends on the NOT NULL proof
+DROP VIEW v_notnull_consumer;
+ALTER TABLE t2_nn ALTER COLUMN t2_t1_id_nn DROP NOT NULL; -- accepted, reason: producer view alone does not store the fact
+SELECT *
+FROM t1_nn JOIN v_notnull_producer p
+-- rejected, reason: view facts are recomputed after the NOT NULL constraint was dropped
+    FOR KEY (t2_t1_id_nn) -> t1_nn (t1_id);
+ALTER TABLE t2_nn ALTER COLUMN t2_t1_id_nn SET NOT NULL;
+DROP VIEW v_notnull_producer;
+
+--
+-- Test nullable referencing columns
+--
+
+-- N1: Nullable FK, INNER JOIN → error
+SELECT * FROM t2_nn
+-- rejected, reason: nullable referencing column is not preserved by an inner key join
+JOIN t1_nn FOR KEY (t1_id) <- t2_nn (t2_t1_id_nullable);
+
+-- N3: Nullable FK, LEFT JOIN preserving referencing side → accepted
+SELECT * FROM t2_nn
+-- accepted
+LEFT JOIN t1_nn FOR KEY (t1_id) <- t2_nn (t2_t1_id_nullable);
+
+-- N4: Nullable FK, RIGHT JOIN preserving referencing side → accepted
+SELECT * FROM t1_nn
+-- accepted
+RIGHT JOIN t2_nn FOR KEY (t2_t1_id_nullable) -> t1_nn (t1_id);
+
+-- N5: Nullable FK, FULL JOIN → accepted
+SELECT * FROM t2_nn
+-- accepted
+FULL JOIN t1_nn FOR KEY (t1_id) <- t2_nn (t2_t1_id_nullable);
+
+-- N7: Nullable FK, LEFT JOIN but referencing on inner side → error
+SELECT * FROM t1_nn
+-- rejected, reason: nullable referencing column is on the non-preserved side
+LEFT JOIN t2_nn FOR KEY (t2_t1_id_nullable) -> t1_nn (t1_id);
+
+-- NE1: NOT NULL FK col null-extended by prior FK LEFT JOIN → null-extended error
+-- t1_nn a LEFT JOIN t2_nn makes t2_t1_id_nn null-extended; the subsequent INNER key join
+-- on that column should fire the null-extended branch of the error.
+SELECT * FROM
+    t1_nn a
+    LEFT JOIN t2_nn FOR KEY (t2_t1_id_nn) -> a (t1_id)
+-- rejected, reason: FK column was null-extended by the prior outer join
+    JOIN t1_nn b FOR KEY (t1_id) <- t2_nn (t2_t1_id_nn);
+
+-- NE2: NOT NULL FK col null-extended through prior outer key joins → error
+CREATE TABLE t1_nn_chain (t1_id INTEGER PRIMARY KEY);
+CREATE TABLE t2_nn_chain (
+    t2_id INTEGER PRIMARY KEY,
+    t2_t1_id INTEGER NOT NULL REFERENCES t1_nn_chain(t1_id)
+);
+CREATE TABLE t3_nn_chain (
+    t3_id INTEGER PRIMARY KEY,
+    t3_t2_id INTEGER NOT NULL UNIQUE REFERENCES t2_nn_chain(t2_id)
+);
+
+INSERT INTO t1_nn_chain VALUES (1), (2);
+INSERT INTO t2_nn_chain VALUES (10, 1), (20, 2);
+INSERT INTO t3_nn_chain VALUES (100, 10);
+
+SELECT * FROM
+    t2_nn_chain m
+-- accepted
+    FULL JOIN t3_nn_chain l FOR KEY (t3_t2_id) -> m (t2_id)
+    LEFT JOIN t2_nn_chain m2 FOR KEY (t2_id) <- l (t3_t2_id)
+-- rejected, reason: FK column was null-extended through prior outer key joins
+    JOIN t1_nn_chain r FOR KEY (t1_id) <- m2 (t2_t1_id);
+
+DROP TABLE t3_nn_chain, t2_nn_chain, t1_nn_chain;
+
+-- NE3: outer padding null-extends every base table in a nested subtree
+CREATE TABLE ne3_a (id INTEGER PRIMARY KEY);
+CREATE TABLE ne3_e (id INTEGER PRIMARY KEY);
+CREATE TABLE ne3_c (
+    id   INTEGER PRIMARY KEY,
+    e_id INTEGER NOT NULL REFERENCES ne3_e(id)
+);
+CREATE TABLE ne3_b (
+    id   INTEGER PRIMARY KEY,
+    a_id INTEGER NOT NULL REFERENCES ne3_a(id),
+    c_id INTEGER NOT NULL REFERENCES ne3_c(id)
+);
+
+INSERT INTO ne3_a VALUES (1), (2);
+INSERT INTO ne3_e VALUES (10);
+INSERT INTO ne3_c VALUES (100, 10);
+INSERT INTO ne3_b VALUES (1000, 1, 100);
+
+SELECT * FROM
+(
+    SELECT ne3_a.id AS a_id, ne3_c.e_id AS c_e_id
+    FROM ne3_a
+-- accepted
+    LEFT JOIN (ne3_c JOIN ne3_b FOR KEY (c_id) -> ne3_c (id))
+        FOR KEY (a_id) -> ne3_a (id)
+) q
+-- rejected, reason: c_e_id is null-extended by the preceding LEFT key join
+JOIN ne3_e FOR KEY (id) <- q (c_e_id);
+
+CREATE VIEW ne3_right_nested_v AS
+SELECT ne3_a.id AS a_id, ne3_c.e_id AS c_e_id
+FROM ne3_a
+-- accepted
+LEFT JOIN (ne3_c JOIN ne3_b FOR KEY (c_id) -> ne3_c (id))
+    FOR KEY (a_id) -> ne3_a (id);
+
+SELECT *
+FROM ne3_right_nested_v q
+-- rejected, reason: c_e_id is null-extended by the stored right-nested key join
+JOIN ne3_e FOR KEY (id) <- q (c_e_id);
+
+DROP VIEW ne3_right_nested_v;
+DROP TABLE ne3_b, ne3_c, ne3_e, ne3_a;
+
+DROP TABLE t2_nn, t1_nn;
+
+CREATE TABLE customer_addresses
+(
+    customer_id INTEGER NOT NULL,
+    address_id INTEGER NOT NULL,
+    CONSTRAINT customer_addresses_pkey             PRIMARY KEY (customer_id, address_id),
+    CONSTRAINT customer_addresses_customer_id_fkey FOREIGN KEY (customer_id) REFERENCES customers (id),
+    CONSTRAINT customer_addresses_address_id_fkey  FOREIGN KEY (address_id) REFERENCES addresses (id)
+);
+
+-- Replacing customer_details to route through customer_addresses would make
+-- customer_details.customer_id appear once per customer address.  The
+-- dependent orders_by_country view needs customer_details.customer_id to
+-- remain unique, so revalidation must reject the producer replacement.
+CREATE OR REPLACE VIEW customer_details AS
+SELECT
+    c.id AS customer_id,
+    c.name AS customer_name,
+    a.street,
+    a.city,
+    a.state,
+    a.country_code,
+    a.zip_code
+FROM customers AS c
+JOIN customer_addresses AS ca FOR KEY (customer_id) -> c (id)
+JOIN addresses AS a FOR KEY (id) <- ca (address_id);
+
+--
+-- Test various error conditions
+--
+
+-- rejected, reason: arrow alias is not present in the left join operand
+SELECT * FROM t1 JOIN t2 FOR KEY (c3, c4) -> t3 (c1, c2);
+
+CREATE SCHEMA key_join_alias_ambiguous;
+CREATE TABLE key_join_alias_ambiguous.t1 (c1 int PRIMARY KEY);
+
+-- rejected, reason: arrow alias matches two visible left-operand relations
+SELECT *
+FROM public.t1 CROSS JOIN key_join_alias_ambiguous.t1
+JOIN t2 FOR KEY (c3) -> t1 (c1);
+
+DROP SCHEMA key_join_alias_ambiguous CASCADE;
+
+-- rejected, reason: referencing and referenced column counts differ
+SELECT * FROM t1 JOIN t2 FOR KEY (c3, c4) -> t1 (c1);
+-- rejected, reason: referencing and referenced column counts differ
+SELECT * FROM t1 JOIN t2 FOR KEY (c3) -> t1 (c1, c2);
+-- rejected, reason: referencing and referenced column counts differ
+SELECT * FROM t1 JOIN t2 FOR KEY (c3, c4) -> t1 (c1, c2, c3);
+-- rejected, reason: referencing column c5 does not exist
+SELECT * FROM t1 JOIN t2 FOR KEY (c3, c4, c5) -> t1 (c1, c2);
+
+CREATE FUNCTION t2() RETURNS TABLE (c3 INTEGER, c4 INTEGER)
+LANGUAGE sql
+RETURN (1, 2);
+
+-- rejected, reason: functions in FROM do not expose surface FK facts
+SELECT * FROM t1 JOIN t2() FOR KEY (c3, c4) -> t1 (c1, c2);
+
+-- rejected, reason: referenced column c5 does not exist
+SELECT * FROM t1 JOIN t2 FOR KEY (c3, c4) -> t1 (c1, c5);
+
+-- rejected, reason: referenced column name is duplicated in the left operand
+SELECT *
+FROM (SELECT c1, c2 AS c1 FROM t1) AS dup_ref
+JOIN t2 FOR KEY (c3) -> dup_ref (c1);
+
+-- rejected, reason: referencing column is not exposed by the left join operand
+SELECT *
+FROM t2 AS fk_keep JOIN t2 AS fk_hidden USING (c3)
+JOIN t1 FOR KEY (c1) <- fk_hidden (c3);
+
+-- rejected, reason: referenced column is not exposed by the left join operand
+SELECT *
+FROM t1 AS pk_keep JOIN t1 AS pk_hidden USING (c1)
+JOIN t2 FOR KEY (c3) -> pk_hidden (c1);
+
+DROP VIEW v1, v2;
+CREATE VIEW v1 AS SELECT c1 AS c1_1, c1 AS c1_2, c2 AS c2_1, c2 AS c2_2 FROM t1;
+CREATE VIEW v2 AS SELECT c3 AS c3_1, c3 AS c3_2, c4 AS c4_1, c4 AS c4_2 FROM t2;
+
+SELECT * FROM v1 JOIN t2 FOR KEY (c3, c4) -> v1 (c1_1, c2_1); -- accepted
+SELECT * FROM v1 JOIN t2 FOR KEY (c3, c4) -> v1 (c1_2, c2_1); -- accepted
+SELECT * FROM v1 JOIN t2 FOR KEY (c3, c4) -> v1 (c1_1, c2_2); -- accepted
+SELECT * FROM v1 JOIN t2 FOR KEY (c3, c4) -> v1 (c1_2, c2_2); -- accepted
+SELECT * FROM v1 JOIN v2 FOR KEY (c3_2, c4_1) -> v1 (c1_2, c2_1); -- accepted
+
+CREATE VIEW v_dup_alias_fk AS
+SELECT * FROM v1 JOIN v2 FOR KEY (c3_2, c4_1) -> v1 (c1_2, c2_2);
+SELECT pg_get_viewdef('v_dup_alias_fk'::regclass, true);
+DROP VIEW v_dup_alias_fk;
+
+-- rejected, reason: selected referenced columns do not map to the composite key
+SELECT * FROM v1 JOIN t2 FOR KEY (c3, c4) -> v1 (c1_1, c1_2);
+
+-- rejected, reason: referenced column does not exist
+SELECT * FROM v1 JOIN t2 FOR KEY (c3, c4) -> v1 (c1_1, nonexistent);
+
+/*
+ * We don't need to check for duplicate columns,
+ * since there is already such a check for foreign key constraints.
+ * Let's test it anyway.
+ */
+-- rejected, reason: duplicate selected columns do not match a foreign key
+SELECT * FROM v1 JOIN t2 FOR KEY (c3, c3) -> v1 (c1_1, c1_1);
+-- rejected, reason: duplicate referencing aliases do not map to the composite FK
+SELECT * FROM v1 JOIN v2 FOR KEY (c3_1, c3_2) -> v1 (c1_1, c2_1);
+
+DROP VIEW v1;
+CREATE VIEW v1 AS SELECT c1+0 AS c1_1, c1 AS c1_2, c2 AS c2_1, c2 AS c2_2 FROM t1;
+-- rejected, reason: referenced key column is an expression, not a direct Var
+SELECT * FROM v1 JOIN t2 FOR KEY (c3, c4) -> v1 (c1_1, c2_1);
+SELECT * FROM v1 JOIN t2 FOR KEY (c3, c4) -> v1 (c1_2, c2_1); -- accepted
+
+SELECT * FROM t1 JOIN
+(
+    SELECT c3, c4 FROM t2
+    UNION ALL
+    SELECT c3, c4 FROM t2
+-- rejected, reason: set operations do not expose surface FK facts
+) AS u FOR KEY (c3, c4) -> t1 (c1, c2);
+
+-- rejected, reason: key join on subquery with GROUPING SETS
+SELECT * FROM t1 JOIN
+(
+    SELECT c3, c4 FROM t2
+    GROUP BY GROUPING SETS ((c3), (c4))
+) AS sub FOR KEY (c3, c4) -> t1 (c1, c2);
+
+-- rejected, reason: key join on subquery with SRF in target list
+SELECT * FROM t1 JOIN
+(
+    SELECT generate_series(1, 3) AS gs, c3, c4 FROM t2
+) AS sub FOR KEY (c3, c4) -> t1 (c1, c2);
+
+SELECT * FROM
+(
+    SELECT c1, c2 FROM t1 WHERE c2 > 0
+) AS u
+-- rejected, reason: referenced-side filter is not matched on the referencing side
+JOIN t2 FOR KEY (c3, c4) -> u (c1, c2);
+
+SELECT *
+FROM t1
+JOIN
+(
+    SELECT * FROM t10
+    JOIN t10 AS t10_2 FOR KEY (c23, c24) <- t10 (c25, c26)
+-- rejected, reason: derived referenced table exposes duplicate key column names
+) AS q1 FOR KEY (c23, c24) -> t1 (c1, c2);
+
+SELECT *
+FROM t1
+JOIN
+(
+    SELECT
+        t10.c23,
+        t10.c24,
+        t10_2.c25,
+        t10_2.c26
+    FROM t10
+-- accepted
+    JOIN t10 AS t10_2 FOR KEY (c23, c24) <- t10 (c25, c26)
+-- rejected, reason: referencing column does not exist in the derived table
+) AS q1 FOR KEY (nonexistent, c24) -> t1 (c1, c2);
+
+SELECT *
+FROM t1
+JOIN
+(
+    SELECT
+        t10.c23,
+        t10_2.c24
+    FROM t10
+-- accepted
+    JOIN t10 AS t10_2 FOR KEY (c23, c24) <- t10 (c25, c26)
+-- rejected, reason: selected derived columns do not preserve the FK mapping
+) AS q1 FOR KEY (c23, c24) -> t1 (c1, c2);
+
+--
+-- Test materialized views (not supported yet)
+--
+
+CREATE MATERIALIZED VIEW mv1 AS
+SELECT c1, c2 FROM t1;
+
+-- rejected, reason: materialized views do not expose surface FK facts
+SELECT * FROM mv1 JOIN t2 FOR KEY (c3, c4) -> mv1 (c1, c2);
+
+DROP MATERIALIZED VIEW mv1;
+
+--
+-- Test PERIOD foreign keys (not key-join proof sources)
+--
+
+CREATE TABLE period_fk_parent
+(
+    id int4range,
+    valid_at daterange,
+    PRIMARY KEY (id, valid_at WITHOUT OVERLAPS),
+    UNIQUE (id, valid_at)
+);
+
+CREATE TABLE period_fk_child
+(
+    id int PRIMARY KEY,
+    parent_id int4range NOT NULL,
+    valid_at daterange NOT NULL,
+    FOREIGN KEY (parent_id, PERIOD valid_at)
+        REFERENCES period_fk_parent (id, PERIOD valid_at)
+);
+
+INSERT INTO period_fk_parent VALUES
+    ('[1,2)', daterange('2020-01-01', '2020-02-01')),
+    ('[1,2)', daterange('2020-02-01', '2020-03-01'));
+INSERT INTO period_fk_child VALUES
+    (1, '[1,2)', daterange('2020-01-15', '2020-02-15'));
+
+SELECT p.id, p.valid_at AS parent_period, c.id, c.valid_at AS child_period
+FROM period_fk_parent p
+-- rejected, reason: PERIOD FK can match multiple referenced periods
+JOIN period_fk_child c FOR KEY (parent_id, valid_at) -> p (id, valid_at);
+
+DROP TABLE period_fk_child, period_fk_parent;
+
+--
+-- Test regular inheritance tables
+--
+
+CREATE TABLE inh_parent_ref
+(
+    id int PRIMARY KEY
+);
+
+CREATE TABLE inh_child_ref () INHERITS (inh_parent_ref);
+
+CREATE TABLE inh_fk_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES inh_parent_ref(id)
+);
+
+INSERT INTO inh_parent_ref VALUES (1);
+INSERT INTO inh_child_ref VALUES (1);
+INSERT INTO inh_fk_child VALUES (1, 1);
+
+SELECT p.id, c.id
+FROM inh_parent_ref p
+-- rejected, reason: inherited parent scans are not supported for key joins
+JOIN inh_fk_child c FOR KEY (parent_id) -> p (id);
+
+SELECT p.id, c.id
+FROM ONLY inh_parent_ref p
+-- accepted
+JOIN inh_fk_child c FOR KEY (parent_id) -> p (id);
+
+DROP TABLE inh_fk_child, inh_child_ref, inh_parent_ref;
+
+--
+-- Test partitioned tables
+--
+
+CREATE TABLE pt2
+(
+    c3 int not null,
+    c4 int not null,
+    CONSTRAINT pt2_pkey PRIMARY KEY (c3),
+    CONSTRAINT pt2_c3_fkey FOREIGN KEY (c3) REFERENCES t1 (c1)
+) PARTITION BY RANGE (c3);
+
+CREATE TABLE pt2_1 PARTITION OF pt2 FOR VALUES FROM (1) TO (3);
+CREATE TABLE pt2_2 PARTITION OF pt2 FOR VALUES FROM (3) TO (4);
+
+CREATE TABLE pt3
+(
+    c5 int not null,
+    c6 int not null,
+    CONSTRAINT pt3_pkey PRIMARY KEY (c5),
+    CONSTRAINT pt3_c5_fkey FOREIGN KEY (c5) REFERENCES pt2 (c3)
+) PARTITION BY RANGE (c5);
+
+CREATE TABLE pt3_1 PARTITION OF pt3 FOR VALUES FROM (1) TO (3);
+CREATE TABLE pt3_2 PARTITION OF pt3 FOR VALUES FROM (3) TO (4);
+
+INSERT INTO pt2 (c3, c4) VALUES (1, 100);
+INSERT INTO pt2 (c3, c4) VALUES (3, 300);
+INSERT INTO pt3 (c5, c6) VALUES (1, 1000);
+INSERT INTO pt3 (c5, c6) VALUES (3, 3000);
+
+-- accepted
+SELECT * FROM t1 JOIN pt2 FOR KEY (c3) -> t1 (c1) JOIN pt3 FOR KEY (c5) -> pt2 (c3);
+-- accepted
+SELECT * FROM t1 JOIN pt2_1 FOR KEY (c3) -> t1 (c1);
+-- rejected, reason: ONLY scans on partitioned tables are not supported
+SELECT * FROM ONLY pt2 JOIN pt3 FOR KEY (c5) -> pt2 (c3);
+
+DROP TABLE pt3;
+DROP TABLE pt2;
+
+SELECT *
+FROM (SELECT * FROM (SELECT c1, c2 FROM t1) AS q1(q1_c1, q1_c2)) q
+-- accepted
+JOIN t2 FOR KEY (c3, c4) -> q (q1_c1, q1_c2);
+-- equivalent to:
+SELECT *
+FROM (WITH q1 (q1_c1, q1_c2) AS (SELECT c1, c2 FROM t1) SELECT * FROM q1) q
+-- accepted
+JOIN t2 FOR KEY (c3, c4) -> q (q1_c1, q1_c2);
+
+DROP TABLE orders CASCADE;
+
+--
+-- The below query should raise the error "key join violation",
+-- "referenced relation does not preserve uniqueness of keys", since
+-- even though there is a UNIQUE constraint on orders.shipment_id,
+-- the orders table doesn't preserve the uniqueness of its keys
+-- due to the join with order_items.
+--
+
+CREATE TABLE shipments
+(
+    id INTEGER PRIMARY KEY,
+    carrier TEXT NOT NULL
+);
+
+CREATE TABLE orders
+(
+    id INTEGER PRIMARY KEY,
+    shipment_id INTEGER UNIQUE NULL REFERENCES shipments(id)
+);
+
+CREATE TABLE order_items
+(
+    id INTEGER PRIMARY KEY,
+    order_id INTEGER NOT NULL REFERENCES orders(id)
+);
+
+CREATE TABLE packages
+(
+    id INTEGER PRIMARY KEY,
+    shipment_id INTEGER NOT NULL REFERENCES shipments(id)
+);
+
+INSERT INTO shipments (id, carrier) VALUES
+  (100, 'UPS'),
+  (200, 'FedEx');
+
+INSERT INTO orders (id, shipment_id) VALUES
+  (10, 100),
+  (20, NULL);
+
+INSERT INTO order_items (id, order_id) VALUES
+  (1000, 10),
+  (1001, 10);
+
+INSERT INTO packages (id, shipment_id) VALUES
+  (900, 100);
+
+CREATE TABLE payments
+(
+    id INTEGER PRIMARY KEY,
+    order_id INTEGER NOT NULL REFERENCES orders(id),
+    amount NUMERIC NOT NULL,
+    method TEXT NOT NULL
+);
+
+INSERT INTO payments (id, order_id, amount, method) VALUES
+  (100, 10, 50.00, 'card'),
+  (200, 20, 29.99, 'card');
+
+--
+-- Inline fan trap: order_items and payments both join to orders via
+-- separate key joins.  The first join (order_items <- orders) consumes
+-- orders.id uniqueness, so the second join (payments -> orders) must
+-- be rejected — orders.id is no longer unique in the intermediate result.
+-- Wrapping the same joins in a view correctly rejects this; inline must
+-- behave identically.
+--
+SELECT *
+FROM order_items
+-- accepted
+JOIN orders FOR KEY (id) <- order_items (order_id)
+JOIN payments FOR KEY (order_id) -> orders (id);
+
+--
+-- Cross-type FK equality semantics are intentionally not supported by
+-- key-join proof.  The text UNIQUE constraint distinguishes 'a' and 'a ',
+-- but the FK to char(2) treats both as the same referenced key value.
+-- If accepted, the first LEFT key join would therefore duplicate p.id, and
+-- the outer key join through q.id must not use c.parent_id's text uniqueness
+-- to preserve p.id uniqueness.
+-- Supporting this would require carrying the distinction between FK
+-- equality and uniqueness equality through later output-fact projection.
+--
+CREATE TABLE eq_sem_parent
+(
+    id char(2) PRIMARY KEY
+);
+CREATE TABLE eq_sem_child
+(
+    id int PRIMARY KEY,
+    parent_id text NOT NULL UNIQUE REFERENCES eq_sem_parent (id)
+);
+CREATE TABLE eq_sem_ref
+(
+    id int PRIMARY KEY,
+    parent_id char(2) NOT NULL REFERENCES eq_sem_parent (id)
+);
+INSERT INTO eq_sem_parent VALUES ('a');
+INSERT INTO eq_sem_child VALUES (1, 'a'), (2, 'a ');
+INSERT INTO eq_sem_ref VALUES (1, 'a');
+
+SELECT p.id AS parent_id, c.id AS child_id, c.parent_id AS child_parent_id
+FROM eq_sem_parent p
+-- rejected, reason: FK and PK key types differ
+LEFT JOIN eq_sem_child c FOR KEY (parent_id) -> p (id)
+ORDER BY c.id;
+
+SELECT q.id AS parent_id, r.id AS ref_id
+FROM (
+    SELECT p.id
+    FROM eq_sem_parent p
+-- rejected, reason: FK and PK key types differ
+    LEFT JOIN eq_sem_child c FOR KEY (parent_id) -> p (id)
+) q
+-- rejected, reason: inner cross-type key join is rejected first
+JOIN eq_sem_ref r FOR KEY (parent_id) -> q (id);
+
+DROP TABLE eq_sem_ref, eq_sem_child, eq_sem_parent;
+
+--
+-- Key-join proof rejects cross-type FK/PK key identity.  PostgreSQL accepts
+-- this FK, but key join deliberately does not compute proof facts from it.
+-- Supporting it would require proving FK containment and referenced-side
+-- uniqueness under different type-specific equality operators.
+--
+CREATE TABLE cross_type_key_parent
+(
+    id numeric PRIMARY KEY
+);
+CREATE TABLE cross_type_key_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES cross_type_key_parent (id)
+);
+INSERT INTO cross_type_key_parent VALUES (1);
+INSERT INTO cross_type_key_child VALUES (10, 1);
+
+SELECT p.id AS parent_id, c.id AS child_id, c.parent_id AS child_parent_id
+FROM cross_type_key_parent p
+-- rejected, reason: FK and PK key types differ
+JOIN cross_type_key_child c FOR KEY (parent_id) -> p (id);
+
+SELECT c.id AS child_id, p.id AS parent_id
+FROM cross_type_key_child c
+-- rejected, reason: FK and PK key types differ
+JOIN cross_type_key_parent p FOR KEY (id) <- c (parent_id);
+
+DROP TABLE cross_type_key_child, cross_type_key_parent;
+
+--
+-- Key-join proof also requires exact typmod identity.
+-- Some typmod pairs may be safe, but proof does not reason about typmod
+-- coercion or typmod-specific equality behavior.
+--
+CREATE TABLE typmod_key_parent
+(
+    id numeric(10,0) PRIMARY KEY
+);
+CREATE TABLE typmod_key_child
+(
+    id int PRIMARY KEY,
+    parent_id numeric(10,2) NOT NULL REFERENCES typmod_key_parent (id)
+);
+INSERT INTO typmod_key_parent VALUES (1);
+INSERT INTO typmod_key_child VALUES (10, 1.00);
+
+SELECT p.id AS parent_id, c.id AS child_id
+FROM typmod_key_parent p
+-- rejected, reason: FK and PK typmods differ
+JOIN typmod_key_child c FOR KEY (parent_id) -> p (id);
+
+DROP TABLE typmod_key_child, typmod_key_parent;
+
+CREATE TABLE varchar_key_customers
+(
+    id varchar(128) PRIMARY KEY
+);
+CREATE TABLE varchar_key_orders
+(
+    customer_id varchar(128) NOT NULL REFERENCES varchar_key_customers (id)
+);
+INSERT INTO varchar_key_customers VALUES ('alice'), ('bob');
+INSERT INTO varchar_key_orders VALUES ('alice'), ('bob');
+
+SELECT *
+FROM varchar_key_customers
+JOIN varchar_key_orders FOR KEY (customer_id) -> varchar_key_customers (id)
+ORDER BY id, customer_id;
+
+SELECT *
+FROM varchar_key_orders
+JOIN varchar_key_customers FOR KEY (id) <- varchar_key_orders (customer_id)
+ORDER BY customer_id, id;
+
+DROP TABLE varchar_key_orders, varchar_key_customers;
+
+CREATE TABLE varchar_typmod_key_parent
+(
+    id varchar(128) PRIMARY KEY
+);
+CREATE TABLE varchar_typmod_key_child
+(
+    parent_id varchar(64) NOT NULL REFERENCES varchar_typmod_key_parent (id)
+);
+INSERT INTO varchar_typmod_key_parent VALUES ('alice');
+INSERT INTO varchar_typmod_key_child VALUES ('alice');
+
+SELECT *
+FROM varchar_typmod_key_parent p
+-- rejected, reason: FK and PK typmods differ
+JOIN varchar_typmod_key_child c FOR KEY (parent_id) -> p (id);
+
+DROP TABLE varchar_typmod_key_child, varchar_typmod_key_parent;
+
+--
+-- Key-join proof requires exact collation identity, even for two
+-- deterministic collations with equivalent behavior.
+-- Supporting this would require proving equivalence of collation semantics,
+-- not just checking that both collations are deterministic.
+--
+CREATE COLLATION key_join_c_copy FROM "C";
+CREATE TABLE collation_key_parent
+(
+    id text COLLATE "C" PRIMARY KEY
+);
+CREATE TABLE collation_key_child
+(
+    id int PRIMARY KEY,
+    parent_id text COLLATE key_join_c_copy NOT NULL REFERENCES collation_key_parent (id)
+);
+INSERT INTO collation_key_parent VALUES ('a');
+INSERT INTO collation_key_child VALUES (10, 'a');
+
+SELECT p.id AS parent_id, c.id AS child_id
+FROM collation_key_parent p
+-- rejected, reason: FK and PK collations differ
+JOIN collation_key_child c FOR KEY (parent_id) -> p (id);
+
+DROP TABLE collation_key_child, collation_key_parent;
+DROP COLLATION key_join_c_copy;
+
+--
+-- FK equality operators used for key-join proof must be immutable.  Temporal
+-- cross-type equality against timestamptz depends on TimeZone, so an FK row
+-- inserted under one TimeZone might not match the referenced row under
+-- another.
+--
+SET TimeZone = 'UTC';
+
+CREATE TABLE timezone_key_timestamp_parent
+(
+    id timestamp PRIMARY KEY
+);
+CREATE TABLE timezone_key_timestamp_child
+(
+    id int PRIMARY KEY,
+    parent_id timestamptz NOT NULL REFERENCES timezone_key_timestamp_parent (id)
+);
+INSERT INTO timezone_key_timestamp_parent VALUES
+    (TIMESTAMP '2020-01-01 00:00:00');
+INSERT INTO timezone_key_timestamp_child VALUES
+    (1, TIMESTAMPTZ '2020-01-01 00:00:00+00');
+
+CREATE TABLE timezone_key_date_parent
+(
+    id date PRIMARY KEY
+);
+CREATE TABLE timezone_key_date_child
+(
+    id int PRIMARY KEY,
+    parent_id timestamptz NOT NULL REFERENCES timezone_key_date_parent (id)
+);
+INSERT INTO timezone_key_date_parent VALUES (DATE '2020-01-01');
+INSERT INTO timezone_key_date_child VALUES
+    (1, TIMESTAMPTZ '2020-01-01 00:00:00+00');
+
+SET TimeZone = 'Europe/Stockholm';
+
+SELECT TIMESTAMP '2020-01-01 00:00:00' =
+       TIMESTAMPTZ '2020-01-01 00:00:00+00' AS timestamp_equality_now;
+SELECT DATE '2020-01-01' =
+       TIMESTAMPTZ '2020-01-01 00:00:00+00' AS date_equality_now;
+
+SELECT c.id AS child_id, p.id AS parent_id
+FROM timezone_key_timestamp_child c
+-- rejected, reason: FK equality operator is not immutable
+JOIN timezone_key_timestamp_parent p FOR KEY (id) <- c (parent_id);
+
+SELECT c.id AS child_id, p.id AS parent_id
+FROM timezone_key_date_child c
+-- rejected, reason: FK equality operator is not immutable
+JOIN timezone_key_date_parent p FOR KEY (id) <- c (parent_id);
+
+RESET TimeZone;
+
+DROP TABLE timezone_key_timestamp_child, timezone_key_timestamp_parent;
+DROP TABLE timezone_key_date_child, timezone_key_date_parent;
+
+--
+-- FK equality operators used for key-join proof must be strict.  Otherwise a
+-- nullable referencing row preserved by an outer key join can match multiple
+-- referenced rows.
+--
+CREATE FUNCTION key_join_nonstrict_eq(a int, b int) RETURNS boolean
+LANGUAGE sql IMMUTABLE AS
+$$ SELECT a IS NULL OR b IS NULL OR a = b $$;
+
+CREATE OPERATOR ===
+(
+    LEFTARG = int,
+    RIGHTARG = int,
+    PROCEDURE = key_join_nonstrict_eq,
+    COMMUTATOR = ===,
+    RESTRICT = eqsel,
+    JOIN = eqjoinsel,
+    MERGES
+);
+
+CREATE OPERATOR CLASS key_join_nonstrict_int4_ops
+FOR TYPE int USING btree AS
+    OPERATOR 1 < (int, int),
+    OPERATOR 2 <= (int, int),
+    OPERATOR 3 === (int, int),
+    OPERATOR 4 >= (int, int),
+    OPERATOR 5 > (int, int),
+    FUNCTION 1 btint4cmp(int, int);
+
+CREATE TABLE key_join_nonstrict_parent
+(
+    id int
+);
+CREATE UNIQUE INDEX key_join_nonstrict_parent_idx
+    ON key_join_nonstrict_parent
+    USING btree (id key_join_nonstrict_int4_ops);
+
+CREATE TABLE key_join_nonstrict_child
+(
+    id int PRIMARY KEY,
+    parent_id int REFERENCES key_join_nonstrict_parent (id)
+);
+
+INSERT INTO key_join_nonstrict_parent VALUES (1), (2);
+INSERT INTO key_join_nonstrict_child VALUES (10, NULL);
+
+SELECT c.id AS child_id, p.id AS parent_id
+FROM key_join_nonstrict_child c
+-- rejected, reason: FK equality operator is not strict
+LEFT JOIN key_join_nonstrict_parent p FOR KEY (id) <- c (parent_id);
+
+DROP TABLE key_join_nonstrict_child,
+           key_join_nonstrict_parent;
+DROP OPERATOR CLASS key_join_nonstrict_int4_ops USING btree;
+DROP OPERATOR FAMILY key_join_nonstrict_int4_ops USING btree;
+DROP OPERATOR === (int, int);
+DROP FUNCTION key_join_nonstrict_eq(int, int);
+
+--
+-- FK equality operators used for key-join proof must match the normalized
+-- referenced-key equality operator.  A domain FK can use a cross-type equality
+-- operator that is valid for RI enforcement but not identical to the base-type
+-- equality member used for same-domain key joins.
+--
+CREATE DOMAIN key_join_ri_mismatch_dom AS int;
+
+CREATE FUNCTION key_join_ri_mismatch_dom_cmp(a key_join_ri_mismatch_dom,
+                                             b key_join_ri_mismatch_dom)
+RETURNS int LANGUAGE sql IMMUTABLE STRICT AS
+$$ SELECT btint4cmp(a::int, b::int) $$;
+
+CREATE FUNCTION key_join_ri_mismatch_dom_int_cmp(a key_join_ri_mismatch_dom,
+                                                 b int)
+RETURNS int LANGUAGE sql IMMUTABLE STRICT AS
+$$ SELECT btint4cmp(a::int, b) $$;
+
+CREATE FUNCTION key_join_ri_mismatch_int_dom_cmp(a int,
+                                                 b key_join_ri_mismatch_dom)
+RETURNS int LANGUAGE sql IMMUTABLE STRICT AS
+$$ SELECT btint4cmp(a, b::int) $$;
+
+CREATE FUNCTION key_join_ri_mismatch_dom_eq(a key_join_ri_mismatch_dom,
+                                            b key_join_ri_mismatch_dom)
+RETURNS boolean LANGUAGE sql IMMUTABLE STRICT AS
+$$ SELECT a::int = b::int $$;
+
+CREATE FUNCTION key_join_ri_mismatch_dom_int_eq(a key_join_ri_mismatch_dom,
+                                                b int)
+RETURNS boolean LANGUAGE sql IMMUTABLE STRICT AS
+$$ SELECT a::int = b $$;
+
+CREATE FUNCTION key_join_ri_mismatch_int_dom_eq(a int,
+                                                b key_join_ri_mismatch_dom)
+RETURNS boolean LANGUAGE sql IMMUTABLE STRICT AS
+$$ SELECT a = b::int $$;
+
+CREATE OPERATOR ===
+(
+    LEFTARG = key_join_ri_mismatch_dom,
+    RIGHTARG = key_join_ri_mismatch_dom,
+    PROCEDURE = key_join_ri_mismatch_dom_eq,
+    COMMUTATOR = ===,
+    RESTRICT = eqsel,
+    JOIN = eqjoinsel,
+    MERGES
+);
+
+CREATE OPERATOR ==#
+(
+    LEFTARG = key_join_ri_mismatch_dom,
+    RIGHTARG = int,
+    PROCEDURE = key_join_ri_mismatch_dom_int_eq,
+    COMMUTATOR = #==,
+    RESTRICT = eqsel,
+    JOIN = eqjoinsel,
+    MERGES
+);
+
+CREATE OPERATOR #==
+(
+    LEFTARG = int,
+    RIGHTARG = key_join_ri_mismatch_dom,
+    PROCEDURE = key_join_ri_mismatch_int_dom_eq,
+    COMMUTATOR = ==#,
+    RESTRICT = eqsel,
+    JOIN = eqjoinsel,
+    MERGES
+);
+
+CREATE OPERATOR FAMILY key_join_ri_mismatch_dom_ops USING btree;
+
+CREATE OPERATOR CLASS key_join_ri_mismatch_dom_ops
+FOR TYPE key_join_ri_mismatch_dom USING btree
+FAMILY key_join_ri_mismatch_dom_ops AS
+    OPERATOR 3 === (key_join_ri_mismatch_dom, key_join_ri_mismatch_dom),
+    FUNCTION 1 key_join_ri_mismatch_dom_cmp(key_join_ri_mismatch_dom,
+                                            key_join_ri_mismatch_dom);
+
+ALTER OPERATOR FAMILY key_join_ri_mismatch_dom_ops USING btree ADD
+    OPERATOR 3 ==# (key_join_ri_mismatch_dom, int),
+    OPERATOR 3 #== (int, key_join_ri_mismatch_dom),
+    OPERATOR 3 = (int, int),
+    FUNCTION 1 key_join_ri_mismatch_dom_int_cmp(key_join_ri_mismatch_dom, int),
+    FUNCTION 1 key_join_ri_mismatch_int_dom_cmp(int, key_join_ri_mismatch_dom),
+    FUNCTION 1 btint4cmp(int, int);
+
+CREATE TABLE key_join_ri_mismatch_parent
+(
+    id key_join_ri_mismatch_dom
+);
+CREATE UNIQUE INDEX key_join_ri_mismatch_parent_idx
+    ON key_join_ri_mismatch_parent
+    USING btree (id key_join_ri_mismatch_dom_ops);
+
+CREATE TABLE key_join_ri_mismatch_child
+(
+    id int PRIMARY KEY,
+    parent_id key_join_ri_mismatch_dom NOT NULL
+        REFERENCES key_join_ri_mismatch_parent (id)
+);
+
+INSERT INTO key_join_ri_mismatch_parent VALUES (1);
+INSERT INTO key_join_ri_mismatch_child VALUES (10, 1);
+
+SELECT c.id AS child_id, p.id AS parent_id
+FROM key_join_ri_mismatch_parent p
+-- rejected, reason: FK equality operator differs from referenced-key equality
+JOIN key_join_ri_mismatch_child c FOR KEY (parent_id) -> p (id);
+
+DROP TABLE key_join_ri_mismatch_child,
+           key_join_ri_mismatch_parent;
+DROP OPERATOR CLASS key_join_ri_mismatch_dom_ops USING btree;
+DROP OPERATOR FAMILY key_join_ri_mismatch_dom_ops USING btree;
+DROP OPERATOR === (key_join_ri_mismatch_dom, key_join_ri_mismatch_dom);
+DROP OPERATOR ==# (key_join_ri_mismatch_dom, int);
+DROP OPERATOR #== (int, key_join_ri_mismatch_dom);
+DROP FUNCTION key_join_ri_mismatch_dom_cmp(key_join_ri_mismatch_dom,
+                                           key_join_ri_mismatch_dom);
+DROP FUNCTION key_join_ri_mismatch_dom_int_cmp(key_join_ri_mismatch_dom, int);
+DROP FUNCTION key_join_ri_mismatch_int_dom_cmp(int, key_join_ri_mismatch_dom);
+DROP FUNCTION key_join_ri_mismatch_dom_eq(key_join_ri_mismatch_dom,
+                                          key_join_ri_mismatch_dom);
+DROP FUNCTION key_join_ri_mismatch_dom_int_eq(key_join_ri_mismatch_dom, int);
+DROP FUNCTION key_join_ri_mismatch_int_dom_eq(int, key_join_ri_mismatch_dom);
+DROP DOMAIN key_join_ri_mismatch_dom;
+
+--
+-- Key join trusts user-defined equality/operator-class contracts exactly as
+-- PostgreSQL trusts them for indexes and constraints.  This deliberately
+-- incoherent opclass is accepted because its catalog properties satisfy the
+-- proof boundary; key join does not attempt to prove operator semantics.
+--
+CREATE FUNCTION key_join_trusted_contract_eq(a int, b int) RETURNS boolean
+LANGUAGE sql IMMUTABLE STRICT AS $$ SELECT (a % 10) = (b % 10) $$;
+
+CREATE OPERATOR ===
+(
+    LEFTARG = int,
+    RIGHTARG = int,
+    PROCEDURE = key_join_trusted_contract_eq,
+    COMMUTATOR = ===,
+    RESTRICT = eqsel,
+    JOIN = eqjoinsel,
+    MERGES
+);
+
+CREATE OPERATOR CLASS key_join_trusted_contract_int4_ops
+FOR TYPE int USING btree AS
+    OPERATOR 1 < (int, int),
+    OPERATOR 2 <= (int, int),
+    OPERATOR 3 === (int, int),
+    OPERATOR 4 >= (int, int),
+    OPERATOR 5 > (int, int),
+    FUNCTION 1 btint4cmp(int, int);
+
+CREATE TABLE key_join_trusted_contract_parent
+(
+    id int NOT NULL
+);
+CREATE UNIQUE INDEX key_join_trusted_contract_parent_idx
+    ON key_join_trusted_contract_parent
+    USING btree (id key_join_trusted_contract_int4_ops);
+
+CREATE TABLE key_join_trusted_contract_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES key_join_trusted_contract_parent (id)
+);
+
+INSERT INTO key_join_trusted_contract_parent VALUES (1), (11);
+INSERT INTO key_join_trusted_contract_child VALUES (1, 1);
+
+SELECT c.id AS child_id, p.id AS parent_id
+FROM key_join_trusted_contract_child c
+JOIN key_join_trusted_contract_parent p FOR KEY (id) <- c (parent_id)
+ORDER BY parent_id;
+
+DROP TABLE key_join_trusted_contract_child,
+           key_join_trusted_contract_parent;
+DROP OPERATOR CLASS key_join_trusted_contract_int4_ops USING btree;
+DROP OPERATOR FAMILY key_join_trusted_contract_int4_ops USING btree;
+DROP OPERATOR === (int, int);
+DROP FUNCTION key_join_trusted_contract_eq(int, int);
+
+--
+-- Stored key joins must depend directly on FK equality operator functions.
+-- Otherwise CREATE OR REPLACE FUNCTION or ALTER FUNCTION could change an
+-- operator implementation after view creation without revalidating the
+-- stored proof.
+--
+CREATE FUNCTION key_join_operator_funcdep_eq(a int, b int) RETURNS boolean
+LANGUAGE sql IMMUTABLE STRICT AS $$ SELECT a = b $$;
+
+CREATE OPERATOR ===
+(
+    LEFTARG = int,
+    RIGHTARG = int,
+    PROCEDURE = key_join_operator_funcdep_eq,
+    COMMUTATOR = ===,
+    RESTRICT = eqsel,
+    JOIN = eqjoinsel,
+    MERGES
+);
+
+CREATE OPERATOR CLASS key_join_operator_funcdep_int4_ops
+FOR TYPE int USING btree AS
+    OPERATOR 1 < (int, int),
+    OPERATOR 2 <= (int, int),
+    OPERATOR 3 === (int, int),
+    OPERATOR 4 >= (int, int),
+    OPERATOR 5 > (int, int),
+    FUNCTION 1 btint4cmp(int, int);
+
+CREATE TABLE key_join_operator_funcdep_parent
+(
+    id int
+);
+CREATE UNIQUE INDEX key_join_operator_funcdep_parent_idx
+    ON key_join_operator_funcdep_parent
+    USING btree (id key_join_operator_funcdep_int4_ops);
+
+CREATE TABLE key_join_operator_funcdep_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES key_join_operator_funcdep_parent (id)
+);
+
+INSERT INTO key_join_operator_funcdep_parent VALUES (1);
+INSERT INTO key_join_operator_funcdep_child VALUES (1, 1);
+
+CREATE VIEW key_join_operator_funcdep_view AS
+SELECT c.id AS child_id, p.id AS parent_id
+FROM key_join_operator_funcdep_parent p
+JOIN key_join_operator_funcdep_child c FOR KEY (parent_id) -> p (id);
+
+SELECT count(*) AS funcdep_view_rows
+FROM key_join_operator_funcdep_view;
+
+CREATE OR REPLACE FUNCTION key_join_operator_funcdep_eq(a int, b int)
+RETURNS boolean
+LANGUAGE sql STABLE STRICT AS $$ SELECT false $$;
+
+SELECT provolatile AS funcdep_volatility
+FROM pg_proc
+WHERE oid = 'key_join_operator_funcdep_eq(int,int)'::regprocedure;
+
+SELECT count(*) AS funcdep_view_rows
+FROM key_join_operator_funcdep_view;
+
+ALTER FUNCTION key_join_operator_funcdep_eq(int, int) STABLE;
+
+SELECT provolatile AS funcdep_volatility
+FROM pg_proc
+WHERE oid = 'key_join_operator_funcdep_eq(int,int)'::regprocedure;
+
+SELECT count(*) AS funcdep_view_rows
+FROM key_join_operator_funcdep_view;
+
+DROP VIEW key_join_operator_funcdep_view;
+DROP TABLE key_join_operator_funcdep_child,
+           key_join_operator_funcdep_parent;
+DROP OPERATOR CLASS key_join_operator_funcdep_int4_ops USING btree;
+DROP OPERATOR FAMILY key_join_operator_funcdep_int4_ops USING btree;
+DROP OPERATOR === (int, int);
+DROP FUNCTION key_join_operator_funcdep_eq(int, int);
+
+--
+-- Reused derived relation identities: two references to the same CTE or
+-- view body must remain distinct table occurrences.  The inner self-key join
+-- duplicates q.pid and drops id = 2 as a referenced key, so the outer key
+-- join through refs_to_nodes.ref_id must be rejected.
+--
+CREATE TABLE self_fk_nodes
+(
+    id INTEGER PRIMARY KEY,
+    parent_id INTEGER NOT NULL REFERENCES self_fk_nodes(id)
+);
+
+CREATE TABLE refs_to_nodes
+(
+    id INTEGER PRIMARY KEY,
+    ref_id INTEGER NOT NULL REFERENCES self_fk_nodes(id)
+);
+
+INSERT INTO self_fk_nodes VALUES (1, 1), (2, 1), (3, 1);
+INSERT INTO refs_to_nodes VALUES (1, 2);
+
+WITH n AS (SELECT id, parent_id FROM self_fk_nodes)
+SELECT *
+FROM (
+    SELECT p.id AS pid, c.id AS cid
+    FROM n p
+-- accepted
+    JOIN n c FOR KEY (parent_id) -> p (id)
+) q
+-- rejected, reason: inner self-key join duplicates the referenced key
+JOIN refs_to_nodes r FOR KEY (ref_id) -> q (pid);
+
+CREATE VIEW self_fk_nodes_v AS
+SELECT id, parent_id FROM self_fk_nodes;
+
+SELECT *
+FROM (
+    SELECT p.id AS pid, c.id AS cid
+    FROM self_fk_nodes_v p
+-- accepted
+    JOIN self_fk_nodes_v c FOR KEY (parent_id) -> p (id)
+) q
+-- rejected, reason: inner self-key join duplicates the referenced key
+JOIN refs_to_nodes r FOR KEY (ref_id) -> q (pid);
+
+SELECT *
+FROM (
+    SELECT *
+    FROM (
+        SELECT p.id AS pid, c.id AS cid
+        FROM self_fk_nodes_v p
+-- accepted
+        JOIN self_fk_nodes_v c FOR KEY (parent_id) -> p (id)
+    ) s
+) q
+-- rejected, reason: inner self-key join duplicates the referenced key
+JOIN refs_to_nodes r FOR KEY (ref_id) -> q (pid);
+
+DROP VIEW self_fk_nodes_v;
+DROP TABLE refs_to_nodes;
+DROP TABLE self_fk_nodes;
+
+CREATE TABLE self_fk_nodes_valid
+(
+    id INTEGER PRIMARY KEY,
+    parent_id INTEGER NOT NULL UNIQUE REFERENCES self_fk_nodes_valid(id)
+);
+
+CREATE TABLE refs_to_nodes_valid
+(
+    id INTEGER PRIMARY KEY,
+    ref_id INTEGER NOT NULL REFERENCES self_fk_nodes_valid(id)
+);
+
+INSERT INTO self_fk_nodes_valid VALUES (1, 1), (2, 2), (3, 3);
+INSERT INTO refs_to_nodes_valid VALUES (1, 2);
+
+WITH n AS (SELECT id, parent_id FROM self_fk_nodes_valid)
+SELECT q.pid, q.cid, r.id AS rid
+FROM (
+    SELECT p.id AS pid, c.id AS cid
+    FROM n p
+-- accepted
+    LEFT JOIN n c FOR KEY (parent_id) -> p (id)
+) q
+JOIN refs_to_nodes_valid r FOR KEY (ref_id) -> q (pid)
+ORDER BY q.pid, q.cid, rid;
+
+CREATE VIEW self_fk_nodes_valid_v AS
+SELECT id, parent_id FROM self_fk_nodes_valid;
+
+SELECT q.pid, q.cid, r.id AS rid
+FROM (
+    SELECT p.id AS pid, c.id AS cid
+    FROM self_fk_nodes_valid_v p
+-- accepted
+    LEFT JOIN self_fk_nodes_valid_v c FOR KEY (parent_id) -> p (id)
+) q
+JOIN refs_to_nodes_valid r FOR KEY (ref_id) -> q (pid)
+ORDER BY q.pid, q.cid, rid;
+
+DROP VIEW self_fk_nodes_valid_v;
+DROP TABLE refs_to_nodes_valid;
+DROP TABLE self_fk_nodes_valid;
+
+--
+-- Correct pattern: pre-aggregate each child, then join through parent.
+-- Each derived table has one row per order_id (GROUP BY guarantees it),
+-- so both LEFT JOINs pass validation.
+--
+SELECT
+    o.id,
+    oi.item_count,
+    p.payment_total
+FROM orders o
+LEFT JOIN (
+    SELECT order_id, count(*) AS item_count
+    FROM order_items
+    GROUP BY order_id
+-- accepted
+) oi FOR KEY (order_id) -> o (id)
+LEFT JOIN (
+    SELECT order_id, SUM(amount) AS payment_total
+    FROM payments
+    GROUP BY order_id
+) p FOR KEY (order_id) -> o (id);
+
+DROP TABLE payments;
+
+--
+-- RLS-enabled relations do not expose key-join facts.  This is rejected
+-- even for the table owner/superuser because stored key-join proofs must
+-- not depend on role-specific RLS bypass behavior.
+--
+CREATE TABLE rls_key_join_parent
+(
+    id int PRIMARY KEY
+);
+CREATE TABLE rls_key_join_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES rls_key_join_parent (id)
+);
+
+ALTER TABLE rls_key_join_parent ENABLE ROW LEVEL SECURITY;
+
+SELECT *
+FROM rls_key_join_parent p
+JOIN rls_key_join_child c FOR KEY (parent_id) -> p (id);
+
+ALTER TABLE rls_key_join_parent DISABLE ROW LEVEL SECURITY;
+ALTER TABLE rls_key_join_child ENABLE ROW LEVEL SECURITY;
+
+SELECT *
+FROM rls_key_join_parent p
+JOIN rls_key_join_child c FOR KEY (parent_id) -> p (id);
+
+DROP TABLE rls_key_join_child, rls_key_join_parent;
+--
+
+--
+-- Test scalar functions alongside key joins.
+-- A scalar function (funcretset = false) returns exactly one row,
+-- so it has trivial uniqueness and row-preservation properties.
+--
+
+-- Scalar function call in SELECT list works fine with key joins
+CREATE FUNCTION scalar_meta() RETURNS int LANGUAGE sql STABLE AS $$ SELECT 42 $$;
+
+SELECT t1.c1, scalar_meta() AS meta, t2.c3
+FROM t1 JOIN t2 FOR KEY (c3) -> t1 (c1); -- accepted
+
+-- View with scalar function in SELECT list and key join in FROM
+CREATE VIEW v_fk_with_meta AS
+SELECT t1.c1, t1.c2, t2.c3, t2.c4, scalar_meta() AS meta
+-- accepted
+FROM t1 LEFT JOIN t2 FOR KEY (c3) -> t1 (c1);
+
+SELECT * FROM v_fk_with_meta; -- accepted
+
+-- key join through the view (scalar in SELECT doesn't affect semantics)
+CREATE TABLE t_child_meta (
+    id int PRIMARY KEY,
+    parent_ref int NOT NULL REFERENCES t1 (c1)
+);
+INSERT INTO t_child_meta VALUES (100, 1), (200, 3);
+
+SELECT v.c1, v.meta, c.id
+FROM v_fk_with_meta v
+JOIN t_child_meta c FOR KEY (parent_ref) -> v (c1); -- accepted
+
+-- View wrapping a scalar function as sole FROM item cannot be
+-- an FK column source (drill_down_to_base_rel has no base table).
+CREATE FUNCTION scalar_row(OUT a int, OUT b int) RETURNS record
+LANGUAGE sql AS $$ SELECT 1, 42 $$;
+
+-- Scalar function as direct FK column source is rejected with clear error
+SELECT t1.c1, s.a
+FROM t1 JOIN scalar_row() AS s FOR KEY (a) -> t1 (c1); -- rejected, reason: functions
+
+-- View wrapping scalar function as FK column source
+CREATE VIEW v_scalar_only AS SELECT * FROM scalar_row();
+
+SELECT v.a, t2.c3
+FROM v_scalar_only v
+JOIN t2 FOR KEY (c3) -> v (a); -- rejected, reason: functions
+
+-- SRF as sole FROM item in a view used as FK column source
+CREATE FUNCTION srf_rows() RETURNS SETOF int LANGUAGE sql
+AS $$ SELECT generate_series(1, 3) $$;
+
+CREATE VIEW v_srf_only AS SELECT * FROM srf_rows() AS s(val);
+
+SELECT v.val, t2.c3
+FROM v_srf_only v
+JOIN t2 FOR KEY (c3) -> v (val); -- rejected, reason: set-returning functions
+
+-- SRF as direct FK column source
+SELECT t1.c1, s.val
+FROM t1 JOIN generate_series(1,3) AS s(val) FOR KEY (val) -> t1 (c1); -- rejected, reason: SRF
+
+-- Cleanup
+DROP VIEW v_srf_only, v_scalar_only, v_fk_with_meta;
+DROP TABLE t_child_meta;
+DROP FUNCTION scalar_meta(), scalar_row(), srf_rows();
+
+-- rejected, reason: key join on data-modifying CTE
+WITH ins AS (INSERT INTO t2 (c3, c4) VALUES (1, 10) RETURNING c3, c4)
+SELECT * FROM t1 JOIN ins FOR KEY (c3, c4) -> t1 (c1, c2);
+
+-- ===================================================================
+-- Test: GROUP BY creates BUNC-set uniqueness (not just restores it)
+-- ===================================================================
+-- GROUP BY on non-unique FK columns should create uniqueness,
+-- allowing the result to be used as referencing side in a key join
+-- that preserves the referenced table's uniqueness.
+
+CREATE TABLE products_gbu (
+    id integer PRIMARY KEY
+);
+CREATE TABLE sales_gbu (
+    product_id integer NOT NULL REFERENCES products_gbu(id),
+    qty numeric NOT NULL,
+    price numeric NOT NULL
+);
+CREATE TABLE resupplies_gbu (
+    product_id integer NOT NULL REFERENCES products_gbu(id),
+    qty numeric NOT NULL,
+    price numeric NOT NULL
+);
+
+INSERT INTO products_gbu VALUES (1), (2), (3);
+INSERT INTO sales_gbu VALUES (1, 10, 5.00), (1, 20, 5.00), (2, 5, 10.00);
+INSERT INTO resupplies_gbu VALUES (1, 100, 4.00), (2, 50, 8.00);
+
+-- Basic: GROUP BY on non-unique FK column creates uniqueness in subquery,
+-- allowing key join from subquery to base table.
+CREATE VIEW v_sales_agg AS
+SELECT
+    p.id,
+    s.total_sold
+FROM products_gbu p
+LEFT JOIN (
+    SELECT product_id, SUM(price * qty) AS total_sold
+    FROM sales_gbu
+    GROUP BY product_id
+-- accepted
+) s FOR KEY (product_id) -> p (id);
+
+SELECT * FROM v_sales_agg ORDER BY id;
+
+-- The view preserves products_gbu uniqueness, so outer key join works.
+SELECT * FROM v_sales_agg
+-- accepted
+JOIN resupplies_gbu FOR KEY (product_id) -> v_sales_agg (id)
+ORDER BY v_sales_agg.id;
+
+-- GROUP BY with more columns than FK: FK columns are a proper subset
+-- of GROUP BY columns, so FK columns are NOT unique.  The view can be
+-- created (the inner key join is fine because products_gbu is a base
+-- table), but using the view as a referenced side should error because
+-- products_gbu uniqueness is not preserved through the join.
+CREATE VIEW v_sales_multi_group AS
+SELECT
+    p.id,
+    s.total_qty
+FROM products_gbu p
+LEFT JOIN (
+    SELECT product_id, price, SUM(qty) AS total_qty
+    FROM sales_gbu
+    GROUP BY product_id, price
+-- accepted
+) s FOR KEY (product_id) -> p (id);
+
+SELECT * FROM v_sales_multi_group
+-- rejected, reason: grouped view does not preserve referenced-side uniqueness
+JOIN resupplies_gbu FOR KEY (product_id) -> v_sales_multi_group (id);
+
+SELECT *
+FROM (
+    SELECT DISTINCT abs(id) AS id
+    FROM products_gbu
+) p
+-- rejected, reason: DISTINCT expression does not prove key uniqueness
+JOIN resupplies_gbu r FOR KEY (product_id) -> p (id);
+
+-- Nondeterministic DISTINCT collations are exercised in key_join_icu.
+
+-- Query-level DISTINCT equality must use an immutable, strict operator.
+SET client_min_messages = warning;
+CREATE TYPE query_unique_nonstrict_type;
+CREATE FUNCTION query_unique_nonstrict_type_in(cstring)
+RETURNS query_unique_nonstrict_type
+LANGUAGE internal STRICT IMMUTABLE AS 'int4in';
+CREATE FUNCTION query_unique_nonstrict_type_out(query_unique_nonstrict_type)
+RETURNS cstring
+LANGUAGE internal STRICT IMMUTABLE AS 'int4out';
+CREATE TYPE query_unique_nonstrict_type (
+    input = query_unique_nonstrict_type_in,
+    output = query_unique_nonstrict_type_out,
+    like = int4
+);
+RESET client_min_messages;
+CREATE CAST (int4 AS query_unique_nonstrict_type) WITHOUT FUNCTION;
+CREATE CAST (query_unique_nonstrict_type AS int4) WITHOUT FUNCTION;
+CREATE FUNCTION query_unique_nonstrict_hash(query_unique_nonstrict_type)
+RETURNS int LANGUAGE internal STRICT IMMUTABLE AS 'hashint4';
+CREATE FUNCTION query_unique_nonstrict_eq(a query_unique_nonstrict_type,
+                                          b query_unique_nonstrict_type)
+RETURNS boolean LANGUAGE sql IMMUTABLE AS
+$$ SELECT a IS NULL OR b IS NULL OR a::int4 = b::int4 $$;
+CREATE OPERATOR === (
+    LEFTARG = query_unique_nonstrict_type,
+    RIGHTARG = query_unique_nonstrict_type,
+    PROCEDURE = query_unique_nonstrict_eq,
+    COMMUTATOR = ===,
+    RESTRICT = eqsel,
+    JOIN = eqjoinsel,
+    HASHES
+);
+CREATE OPERATOR CLASS query_unique_nonstrict_type_ops
+DEFAULT FOR TYPE query_unique_nonstrict_type USING hash AS
+    OPERATOR 1 === (query_unique_nonstrict_type,
+                    query_unique_nonstrict_type),
+    FUNCTION 1 query_unique_nonstrict_hash(query_unique_nonstrict_type);
+CREATE TABLE query_unique_nonstrict_parent (id query_unique_nonstrict_type);
+CREATE TABLE query_unique_nonstrict_child (parent_id query_unique_nonstrict_type);
+SELECT *
+FROM (SELECT DISTINCT id FROM query_unique_nonstrict_parent) p
+-- rejected, reason: DISTINCT equality operator is not strict
+JOIN query_unique_nonstrict_child c FOR KEY (parent_id) -> p (id);
+DROP TABLE query_unique_nonstrict_child, query_unique_nonstrict_parent;
+DROP OPERATOR CLASS query_unique_nonstrict_type_ops USING hash;
+DROP OPERATOR === (query_unique_nonstrict_type, query_unique_nonstrict_type);
+DROP FUNCTION query_unique_nonstrict_eq(query_unique_nonstrict_type,
+                                        query_unique_nonstrict_type);
+DROP FUNCTION query_unique_nonstrict_hash(query_unique_nonstrict_type);
+DROP CAST (query_unique_nonstrict_type AS int4);
+DROP CAST (int4 AS query_unique_nonstrict_type);
+SET client_min_messages = warning;
+DROP TYPE query_unique_nonstrict_type CASCADE;
+RESET client_min_messages;
+
+-- DISTINCT on non-unique FK column also creates uniqueness.
+CREATE VIEW v_sales_distinct AS
+SELECT
+    p.id,
+    s.product_id AS s_product_id
+FROM products_gbu p
+LEFT JOIN (
+    SELECT DISTINCT product_id
+    FROM sales_gbu
+-- accepted
+) s FOR KEY (product_id) -> p (id);
+
+SELECT * FROM v_sales_distinct ORDER BY id;
+
+-- DISTINCT ON a non-key column can discard referenced keys, so it must not
+-- preserve the referenced relation's row set for a later key join.
+CREATE TABLE products_do (
+    id integer PRIMARY KEY,
+    grp integer NOT NULL
+);
+CREATE TABLE reviews_do (
+    id integer PRIMARY KEY,
+    product_id integer NOT NULL REFERENCES products_do(id)
+);
+
+INSERT INTO products_do VALUES (1, 10), (2, 10);
+INSERT INTO reviews_do VALUES (1, 1), (2, 2);
+
+SELECT q.id, r.id
+FROM (SELECT DISTINCT ON (grp) id FROM products_do ORDER BY grp, id) q
+-- rejected, reason: DISTINCT ON does not cover the referenced key
+JOIN reviews_do r FOR KEY (product_id) -> q (id);
+
+DROP TABLE reviews_do, products_do;
+
+-- DISTINCT ON drops sibling rows for each distinct group, so a base
+-- rowCoverage on the wider key is no longer valid for the resulting
+-- surface.  An outer GROUP BY (or DISTINCT) on the wider key supplies a
+-- query-shape unique that, paired with the now-bogus catalog rowCoverage,
+-- would let a key join through and silently drop FK-bearing rows whose
+-- referenced row was dropped by the inner DISTINCT ON.
+CREATE TABLE rowcov_pk (a int NOT NULL, b int NOT NULL, PRIMARY KEY (a, b));
+CREATE TABLE rowcov_fk (fk_a int NOT NULL, fk_b int NOT NULL,
+                        FOREIGN KEY (fk_a, fk_b) REFERENCES rowcov_pk (a, b));
+INSERT INTO rowcov_pk VALUES (1, 1), (1, 2), (2, 1);
+INSERT INTO rowcov_fk VALUES (1, 2);
+
+SELECT *
+FROM (
+    SELECT a, b
+    FROM (SELECT DISTINCT ON (a) a, b FROM rowcov_pk ORDER BY a, b) i
+    GROUP BY a, b
+) sub
+-- rejected, reason: inner DISTINCT ON (a) drops sibling rows so (a, b)
+-- row coverage is lost regardless of the outer GROUP BY
+JOIN rowcov_fk FOR KEY (fk_a, fk_b) -> sub (a, b);
+
+SELECT *
+FROM (
+    SELECT DISTINCT a, b
+    FROM (SELECT DISTINCT ON (a) a, b FROM rowcov_pk ORDER BY a, b) i
+) sub
+-- rejected, reason: same anti-pattern using DISTINCT instead of GROUP BY
+JOIN rowcov_fk FOR KEY (fk_a, fk_b) -> sub (a, b);
+
+DROP TABLE rowcov_fk, rowcov_pk;
+
+-- GROUP BY/DISTINCT preserve row coverage only when their equality identity
+-- exactly matches the key equality identity.
+CREATE TABLE rowcov_exact_parent (id int PRIMARY KEY);
+CREATE TABLE rowcov_exact_child (
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES rowcov_exact_parent (id)
+);
+INSERT INTO rowcov_exact_parent VALUES (1), (2);
+INSERT INTO rowcov_exact_child VALUES (10, 1), (20, 2);
+
+SELECT p.id, c.id AS child_id
+FROM (SELECT id FROM rowcov_exact_parent GROUP BY id) p
+-- accepted
+JOIN rowcov_exact_child c FOR KEY (parent_id) -> p (id)
+ORDER BY p.id, c.id;
+
+SELECT p.id, c.id AS child_id
+FROM (SELECT DISTINCT id FROM rowcov_exact_parent) p
+-- accepted
+JOIN rowcov_exact_child c FOR KEY (parent_id) -> p (id)
+ORDER BY p.id, c.id;
+
+DROP TABLE rowcov_exact_child, rowcov_exact_parent;
+
+-- GROUP BY/DISTINCT preserve row coverage for nullable referenced keys when
+-- the collapsed key covers the same all-non-null values under the same key
+-- identity.  Null-containing referenced rows are outside the referenced
+-- multiset that key joins must cover.
+CREATE TABLE rowcov_nullable_parent (
+    id int PRIMARY KEY,
+    code text UNIQUE
+);
+CREATE TABLE rowcov_nullable_child (
+    id int PRIMARY KEY,
+    parent_code text NOT NULL REFERENCES rowcov_nullable_parent (code)
+);
+INSERT INTO rowcov_nullable_parent VALUES
+    (1, 'a'), (2, 'b'), (3, NULL), (4, NULL);
+INSERT INTO rowcov_nullable_child VALUES (10, 'a'), (20, 'b');
+
+SELECT p.code, c.id AS child_id
+FROM (SELECT code FROM rowcov_nullable_parent GROUP BY code) p
+-- accepted
+JOIN rowcov_nullable_child c FOR KEY (parent_code) -> p (code)
+ORDER BY p.code, c.id;
+
+SELECT p.code, c.id AS child_id
+FROM (SELECT DISTINCT code FROM rowcov_nullable_parent) p
+-- accepted
+JOIN rowcov_nullable_child c FOR KEY (parent_code) -> p (code)
+ORDER BY p.code, c.id;
+
+SELECT p.code, c.id AS child_id
+FROM (SELECT code FROM rowcov_nullable_parent GROUP BY code HAVING count(*) > 0) p
+-- rejected, reason: HAVING remains a row-removal barrier
+JOIN rowcov_nullable_child c FOR KEY (parent_code) -> p (code);
+
+DROP TABLE rowcov_nullable_child, rowcov_nullable_parent;
+
+-- Extra row-collapse columns may have different identity from the key.
+-- Row coverage is preserved as long as the grouped key column itself has
+-- the exact key identity.
+CREATE TABLE rowcov_identity_type_parent (
+    label text NOT NULL,
+    id int PRIMARY KEY
+);
+CREATE TABLE rowcov_identity_type_child (
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES rowcov_identity_type_parent (id)
+);
+INSERT INTO rowcov_identity_type_parent VALUES ('a', 1), ('b', 2);
+INSERT INTO rowcov_identity_type_child VALUES (10, 1), (20, 2);
+
+SELECT p.id, c.id AS child_id
+FROM (
+    SELECT label, id
+    FROM rowcov_identity_type_parent
+    GROUP BY label, id
+) p
+-- accepted, reason: extra grouped column has different type
+JOIN rowcov_identity_type_child c FOR KEY (parent_id) -> p (id)
+ORDER BY p.id, c.id;
+
+DROP TABLE rowcov_identity_type_child, rowcov_identity_type_parent;
+
+CREATE TABLE rowcov_identity_typmod_parent (
+    amount numeric(5,0) NOT NULL,
+    id numeric(10,0) PRIMARY KEY
+);
+CREATE TABLE rowcov_identity_typmod_child (
+    id int PRIMARY KEY,
+    parent_id numeric(10,0) NOT NULL
+        REFERENCES rowcov_identity_typmod_parent (id)
+);
+INSERT INTO rowcov_identity_typmod_parent VALUES (1, 10), (2, 20);
+INSERT INTO rowcov_identity_typmod_child VALUES (10, 10), (20, 20);
+
+SELECT p.id, c.id AS child_id
+FROM (
+    SELECT amount, id
+    FROM rowcov_identity_typmod_parent
+    GROUP BY amount, id
+) p
+-- accepted, reason: extra grouped column has different typmod
+JOIN rowcov_identity_typmod_child c FOR KEY (parent_id) -> p (id)
+ORDER BY p.id, c.id;
+
+DROP TABLE rowcov_identity_typmod_child, rowcov_identity_typmod_parent;
+
+CREATE TABLE rowcov_identity_collation_parent (
+    label text COLLATE "POSIX" NOT NULL,
+    id text COLLATE "C" PRIMARY KEY
+);
+CREATE TABLE rowcov_identity_collation_child (
+    id int PRIMARY KEY,
+    parent_id text COLLATE "C" NOT NULL
+        REFERENCES rowcov_identity_collation_parent (id)
+);
+INSERT INTO rowcov_identity_collation_parent VALUES ('a', 'x'), ('b', 'y');
+INSERT INTO rowcov_identity_collation_child VALUES (10, 'x'), (20, 'y');
+
+SELECT p.id, c.id AS child_id
+FROM (
+    SELECT label, id
+    FROM rowcov_identity_collation_parent
+    GROUP BY label, id
+) p
+-- accepted, reason: extra grouped column has different collation
+JOIN rowcov_identity_collation_child c FOR KEY (parent_id) -> p (id)
+ORDER BY p.id, c.id;
+
+DROP TABLE rowcov_identity_collation_child, rowcov_identity_collation_parent;
+
+CREATE FUNCTION rowcov_numeric_scale_cmp(a numeric, b numeric) RETURNS int
+LANGUAGE sql IMMUTABLE STRICT AS
+$$ SELECT CASE WHEN a < b THEN -1
+               WHEN a > b THEN 1
+               WHEN scale(a) < scale(b) THEN -1
+               WHEN scale(a) > scale(b) THEN 1
+               ELSE 0 END $$;
+
+CREATE FUNCTION rowcov_numeric_scale_eq(a numeric, b numeric) RETURNS boolean
+LANGUAGE sql IMMUTABLE STRICT AS $$ SELECT a = b AND scale(a) = scale(b) $$;
+
+CREATE FUNCTION rowcov_numeric_scale_lt(a numeric, b numeric) RETURNS boolean
+LANGUAGE sql IMMUTABLE STRICT AS
+$$ SELECT rowcov_numeric_scale_cmp(a, b) < 0 $$;
+
+CREATE OPERATOR =# (
+    LEFTARG = numeric,
+    RIGHTARG = numeric,
+    FUNCTION = rowcov_numeric_scale_eq,
+    COMMUTATOR = =#,
+    RESTRICT = eqsel,
+    JOIN = eqjoinsel,
+    MERGES
+);
+
+CREATE OPERATOR <# (
+    LEFTARG = numeric,
+    RIGHTARG = numeric,
+    FUNCTION = rowcov_numeric_scale_lt
+);
+
+CREATE OPERATOR CLASS rowcov_numeric_scale_ops
+FOR TYPE numeric USING btree AS
+    OPERATOR 1 <# (numeric, numeric),
+    OPERATOR 3 =# (numeric, numeric),
+    FUNCTION 1 rowcov_numeric_scale_cmp(numeric, numeric);
+
+CREATE TABLE rowcov_numeric_parent (id numeric NOT NULL);
+CREATE UNIQUE INDEX rowcov_numeric_parent_id_idx
+    ON rowcov_numeric_parent USING btree (id rowcov_numeric_scale_ops);
+CREATE TABLE rowcov_numeric_child (
+    id int PRIMARY KEY,
+    parent_id numeric NOT NULL REFERENCES rowcov_numeric_parent (id)
+);
+INSERT INTO rowcov_numeric_parent VALUES (1.0), (1.00);
+INSERT INTO rowcov_numeric_child VALUES (10, 1.00);
+
+SELECT *
+FROM (SELECT id FROM rowcov_numeric_parent GROUP BY id) p
+-- rejected, reason: GROUP BY equality collapses keys distinct under FK equality
+JOIN rowcov_numeric_child c FOR KEY (parent_id) -> p (id);
+
+SELECT *
+FROM (SELECT DISTINCT id FROM rowcov_numeric_parent) p
+-- rejected, reason: DISTINCT equality collapses keys distinct under FK equality
+JOIN rowcov_numeric_child c FOR KEY (parent_id) -> p (id);
+
+SELECT *
+FROM (
+    SELECT DISTINCT ON (id) id
+    FROM rowcov_numeric_parent
+    ORDER BY id, id USING <#
+) p
+-- rejected, reason: duplicate DISTINCT ON ordering adds no key coverage
+JOIN rowcov_numeric_child c FOR KEY (parent_id) -> p (id);
+
+CREATE VIEW rowcov_numeric_group_v AS
+SELECT id FROM rowcov_numeric_parent GROUP BY id;
+SELECT *
+FROM rowcov_numeric_group_v p
+-- rejected, reason: GROUP BY row coverage loss occurred inside the view
+JOIN rowcov_numeric_child c FOR KEY (parent_id) -> p (id);
+DROP VIEW rowcov_numeric_group_v;
+
+CREATE VIEW rowcov_numeric_distinct_v AS
+SELECT DISTINCT id FROM rowcov_numeric_parent;
+SELECT *
+FROM rowcov_numeric_distinct_v p
+-- rejected, reason: DISTINCT row coverage loss occurred inside the view
+JOIN rowcov_numeric_child c FOR KEY (parent_id) -> p (id);
+DROP VIEW rowcov_numeric_distinct_v;
+
+DROP TABLE rowcov_numeric_child, rowcov_numeric_parent;
+DROP OPERATOR CLASS rowcov_numeric_scale_ops USING btree;
+DROP OPERATOR FAMILY rowcov_numeric_scale_ops USING btree;
+DROP OPERATOR <# (numeric, numeric);
+DROP OPERATOR =# (numeric, numeric);
+DROP FUNCTION rowcov_numeric_scale_cmp(numeric, numeric);
+DROP FUNCTION rowcov_numeric_scale_eq(numeric, numeric);
+DROP FUNCTION rowcov_numeric_scale_lt(numeric, numeric);
+
+CREATE SEQUENCE volatile_default_seq;
+CREATE FUNCTION volatile_default_int(v int DEFAULT nextval('volatile_default_seq')::int)
+RETURNS int
+LANGUAGE sql STABLE AS $$ SELECT v $$;
+
+CREATE VIEW volatile_target_parent_v AS
+SELECT id, set_config('key_join.volatile', id::text, false) AS side_effect
+FROM products_gbu;
+
+SELECT *
+FROM volatile_target_parent_v p
+-- rejected, reason: volatile targetlist blocks surface row coverage
+JOIN resupplies_gbu r FOR KEY (product_id) -> p (id);
+
+DROP VIEW volatile_target_parent_v;
+
+CREATE VIEW volatile_default_target_parent_v AS
+SELECT id, volatile_default_int() AS side_effect
+FROM products_gbu;
+
+SELECT *
+FROM volatile_default_target_parent_v p
+-- rejected, reason: volatile default targetlist blocks surface row coverage
+JOIN resupplies_gbu r FOR KEY (product_id) -> p (id);
+
+DROP VIEW volatile_default_target_parent_v;
+
+DROP FUNCTION volatile_default_int(int);
+DROP SEQUENCE volatile_default_seq;
+
+-- Cleanup
+DROP VIEW v_sales_agg, v_sales_multi_group, v_sales_distinct;
+DROP TABLE resupplies_gbu, sales_gbu, products_gbu CASCADE;
+
+-- ============================================================
+-- FILTER clause tests
+-- ============================================================
+
+-- FILTER1: key join with FILTER clause — restricts result to t2.c4 = 10
+SELECT * FROM t1
+-- accepted
+  JOIN t2 FOR KEY (c3) -> t1 (c1)
+  FILTER (WHERE t2.c4 = 10);
+
+CREATE TABLE scalar_array_filter_parent (id boolean PRIMARY KEY);
+CREATE TABLE scalar_array_filter_child
+(
+    id int PRIMARY KEY,
+    parent_id boolean NOT NULL REFERENCES scalar_array_filter_parent (id)
+);
+INSERT INTO scalar_array_filter_parent VALUES (false), (true);
+INSERT INTO scalar_array_filter_child VALUES (1, true), (2, false);
+
+CREATE VIEW scalar_array_filter_parent_v AS
+SELECT id
+FROM scalar_array_filter_parent
+WHERE id = (true = ANY (ARRAY[false, length('x') = 1]));
+
+CREATE VIEW scalar_array_filter_child_v AS
+SELECT parent_id
+FROM scalar_array_filter_child
+WHERE parent_id = (true = ANY (ARRAY[false, length('x') = 1]));
+
+SELECT count(*) AS scalar_array_filter_rows
+FROM scalar_array_filter_parent_v p
+-- rejected, reason: ScalarArrayOpExpr filter values are not proof-filter terms
+JOIN scalar_array_filter_child_v c FOR KEY (parent_id) -> p (id);
+
+DROP VIEW scalar_array_filter_child_v, scalar_array_filter_parent_v;
+DROP TABLE scalar_array_filter_child, scalar_array_filter_parent;
+
+-- FILTER1b: strict proof-filter value allowlist accepts constants
+CREATE TABLE filter_const_parent (id int PRIMARY KEY);
+CREATE TABLE filter_const_child
+(
+    parent_id int NOT NULL REFERENCES filter_const_parent (id)
+);
+INSERT INTO filter_const_parent VALUES (1), (2);
+INSERT INTO filter_const_child VALUES (1);
+CREATE VIEW filter_const_parent_v AS
+SELECT id FROM filter_const_parent WHERE id = 1::integer;
+CREATE VIEW filter_const_child_v AS
+SELECT parent_id FROM filter_const_child WHERE parent_id = 1::integer;
+SELECT count(*) AS const_filter_rows
+FROM filter_const_parent_v p
+JOIN filter_const_child_v c FOR KEY (parent_id) -> p (id);
+DROP VIEW filter_const_child_v, filter_const_parent_v;
+DROP TABLE filter_const_child, filter_const_parent;
+
+-- FILTER1c: strict proof-filter value allowlist accepts SQL value functions
+CREATE TABLE filter_sqlvalue_parent (id name PRIMARY KEY);
+CREATE TABLE filter_sqlvalue_child
+(
+    parent_id name NOT NULL REFERENCES filter_sqlvalue_parent (id)
+);
+INSERT INTO filter_sqlvalue_parent VALUES (CURRENT_USER);
+INSERT INTO filter_sqlvalue_child VALUES (CURRENT_USER);
+CREATE VIEW filter_sqlvalue_parent_v AS
+SELECT id FROM filter_sqlvalue_parent WHERE id = CURRENT_USER;
+CREATE VIEW filter_sqlvalue_child_v AS
+SELECT parent_id FROM filter_sqlvalue_child WHERE parent_id = CURRENT_USER;
+SELECT count(*) AS sqlvalue_filter_rows
+FROM filter_sqlvalue_parent_v p
+JOIN filter_sqlvalue_child_v c FOR KEY (parent_id) -> p (id);
+DROP VIEW filter_sqlvalue_child_v, filter_sqlvalue_parent_v;
+DROP TABLE filter_sqlvalue_child, filter_sqlvalue_parent;
+
+-- FILTER1c-2: SQL value function filters on varchar keys use text = name
+CREATE TABLE filter_varchar_tenant_customers
+(
+    tenant_id varchar(128),
+    id int,
+    name varchar(100),
+    PRIMARY KEY (tenant_id, id)
+);
+CREATE TABLE filter_varchar_tenant_orders
+(
+    tenant_id varchar(128),
+    id int,
+    customer_id int NOT NULL,
+    amount numeric(10,2),
+    PRIMARY KEY (tenant_id, id),
+    FOREIGN KEY (tenant_id, customer_id)
+        REFERENCES filter_varchar_tenant_customers (tenant_id, id)
+);
+INSERT INTO filter_varchar_tenant_customers
+VALUES (CURRENT_USER, 1, 'Alice'), ('other_tenant', 1, 'Other');
+INSERT INTO filter_varchar_tenant_orders
+VALUES (CURRENT_USER, 10, 1, 5.00), ('other_tenant', 20, 1, 7.00);
+CREATE VIEW filter_varchar_tenant_customers_v AS
+SELECT * FROM filter_varchar_tenant_customers
+WHERE tenant_id = CURRENT_USER;
+CREATE VIEW filter_varchar_tenant_orders_v AS
+SELECT * FROM filter_varchar_tenant_orders
+WHERE tenant_id = CURRENT_USER;
+SELECT o.id, c.name
+FROM filter_varchar_tenant_orders_v o
+JOIN filter_varchar_tenant_customers_v c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id)
+ORDER BY o.id, c.name;
+SELECT count(*) AS varchar_sqlvalue_filter_unmatched_rows
+FROM filter_varchar_tenant_orders o
+-- rejected, reason: the referenced-side CURRENT_USER filter is not matched on the FK side
+JOIN filter_varchar_tenant_customers_v c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+DROP VIEW filter_varchar_tenant_orders_v, filter_varchar_tenant_customers_v;
+DROP TABLE filter_varchar_tenant_orders, filter_varchar_tenant_customers;
+
+-- FILTER1d: strict proof-filter value allowlist accepts relabel casts
+CREATE TABLE filter_relabel_parent (id text PRIMARY KEY);
+CREATE TABLE filter_relabel_child
+(
+    parent_id text NOT NULL REFERENCES filter_relabel_parent (id)
+);
+INSERT INTO filter_relabel_parent VALUES ('a'), ('b');
+INSERT INTO filter_relabel_child VALUES ('a');
+CREATE VIEW filter_relabel_parent_v AS
+SELECT id FROM filter_relabel_parent
+WHERE id = ('a'::varchar)::text;
+CREATE VIEW filter_relabel_child_v AS
+SELECT parent_id FROM filter_relabel_child
+WHERE parent_id = ('a'::varchar)::text;
+SELECT count(*) AS relabel_filter_rows
+FROM filter_relabel_parent_v p
+JOIN filter_relabel_child_v c FOR KEY (parent_id) -> p (id);
+DROP VIEW filter_relabel_child_v, filter_relabel_parent_v;
+DROP TABLE filter_relabel_child, filter_relabel_parent;
+
+-- FILTER1e: strict proof-filter value allowlist accepts stable functions
+CREATE FUNCTION filter_stable_identity(v int) RETURNS int
+LANGUAGE sql STABLE AS $$ SELECT v $$;
+CREATE TABLE filter_func_parent (id int PRIMARY KEY);
+CREATE TABLE filter_func_child
+(
+    parent_id int NOT NULL REFERENCES filter_func_parent (id)
+);
+INSERT INTO filter_func_parent VALUES (1), (2);
+INSERT INTO filter_func_child VALUES (1);
+CREATE VIEW filter_func_parent_v AS
+SELECT id FROM filter_func_parent WHERE id = filter_stable_identity(1);
+CREATE VIEW filter_func_child_v AS
+SELECT parent_id FROM filter_func_child WHERE parent_id = filter_stable_identity(1);
+SELECT count(*) AS func_filter_rows
+FROM filter_func_parent_v p
+JOIN filter_func_child_v c FOR KEY (parent_id) -> p (id);
+DROP VIEW filter_func_child_v, filter_func_parent_v;
+
+CREATE VIEW filter_func_arg_parent_v AS
+SELECT id FROM filter_func_parent
+WHERE id = filter_stable_identity(LEAST(1, 2));
+CREATE VIEW filter_func_arg_child_v AS
+SELECT parent_id FROM filter_func_child
+WHERE parent_id = filter_stable_identity(LEAST(1, 2));
+SELECT count(*) AS func_arg_filter_rows
+FROM filter_func_arg_parent_v p
+-- rejected, reason: function arguments must be proof-filter terms
+JOIN filter_func_arg_child_v c FOR KEY (parent_id) -> p (id);
+DROP VIEW filter_func_arg_child_v, filter_func_arg_parent_v;
+
+DROP TABLE filter_func_child, filter_func_parent;
+DROP FUNCTION filter_stable_identity(int);
+
+-- FILTER1f: strict proof-filter value allowlist rejects volatile functions
+CREATE FUNCTION filter_volatile_identity(v int) RETURNS int
+LANGUAGE sql VOLATILE AS $$ SELECT v $$;
+CREATE TABLE filter_volatile_parent (id int PRIMARY KEY);
+CREATE TABLE filter_volatile_child
+(
+    parent_id int NOT NULL REFERENCES filter_volatile_parent (id)
+);
+INSERT INTO filter_volatile_parent VALUES (1), (2);
+INSERT INTO filter_volatile_child VALUES (1);
+CREATE VIEW filter_volatile_parent_v AS
+SELECT id FROM filter_volatile_parent WHERE id = filter_volatile_identity(1);
+CREATE VIEW filter_volatile_child_v AS
+SELECT parent_id FROM filter_volatile_child WHERE parent_id = filter_volatile_identity(1);
+SELECT count(*) AS volatile_filter_rows
+FROM filter_volatile_parent_v p
+-- rejected, reason: volatile function filter values are not proof-filter terms
+JOIN filter_volatile_child_v c FOR KEY (parent_id) -> p (id);
+DROP VIEW filter_volatile_child_v, filter_volatile_parent_v;
+DROP TABLE filter_volatile_child, filter_volatile_parent;
+DROP FUNCTION filter_volatile_identity(int);
+
+-- FILTER1f2: strict proof-filter allowlist rejects volatile defaults
+CREATE SEQUENCE filter_volatile_default_seq;
+CREATE FUNCTION filter_volatile_default_identity(v int DEFAULT nextval('filter_volatile_default_seq')::int)
+RETURNS int
+LANGUAGE sql STABLE AS $$ SELECT v $$;
+CREATE TABLE filter_volatile_default_parent (id int PRIMARY KEY);
+CREATE TABLE filter_volatile_default_child
+(
+    parent_id int NOT NULL REFERENCES filter_volatile_default_parent (id)
+);
+INSERT INTO filter_volatile_default_parent VALUES (1), (2);
+INSERT INTO filter_volatile_default_child VALUES (1);
+CREATE VIEW filter_volatile_default_parent_v AS
+SELECT id FROM filter_volatile_default_parent
+WHERE id = filter_volatile_default_identity();
+CREATE VIEW filter_volatile_default_child_v AS
+SELECT parent_id FROM filter_volatile_default_child
+WHERE parent_id = filter_volatile_default_identity();
+SELECT count(*) AS volatile_default_filter_rows
+FROM filter_volatile_default_parent_v p
+-- rejected, reason: volatile default function filter values are not proof terms
+JOIN filter_volatile_default_child_v c FOR KEY (parent_id) -> p (id);
+DROP VIEW filter_volatile_default_child_v, filter_volatile_default_parent_v;
+DROP TABLE filter_volatile_default_child, filter_volatile_default_parent;
+DROP FUNCTION filter_volatile_default_identity(int);
+DROP SEQUENCE filter_volatile_default_seq;
+
+-- FILTER1g: strict proof-filter value allowlist rejects volatile CoerceViaIO
+-- input/output functions before row-coverage propagation.
+SET client_min_messages = warning;
+CREATE TYPE filter_coerce_io_type;
+CREATE FUNCTION filter_coerce_io_type_in(cstring)
+RETURNS filter_coerce_io_type
+LANGUAGE internal STRICT IMMUTABLE AS 'int4in';
+CREATE FUNCTION filter_coerce_io_type_out(filter_coerce_io_type)
+RETURNS cstring
+LANGUAGE internal STRICT IMMUTABLE AS 'int4out';
+CREATE TYPE filter_coerce_io_type (
+    input = filter_coerce_io_type_in,
+    output = filter_coerce_io_type_out,
+    like = int4
+);
+RESET client_min_messages;
+CREATE CAST (filter_coerce_io_type AS integer) WITH INOUT;
+CREATE TABLE filter_coerce_io_parent (id int PRIMARY KEY);
+CREATE TABLE filter_coerce_io_child
+(
+    id int NOT NULL UNIQUE REFERENCES filter_coerce_io_parent (id)
+);
+CREATE TABLE filter_coerce_io_grandchild
+(
+    id int NOT NULL REFERENCES filter_coerce_io_child (id)
+);
+INSERT INTO filter_coerce_io_parent VALUES (1), (2);
+INSERT INTO filter_coerce_io_child VALUES (1), (2);
+INSERT INTO filter_coerce_io_grandchild VALUES (1), (1), (2);
+ALTER FUNCTION filter_coerce_io_type_out(filter_coerce_io_type) VOLATILE;
+SELECT count(*) AS volatile_coerceviaio_filter_rows
+FROM (
+    filter_coerce_io_parent p
+    JOIN filter_coerce_io_child c FOR KEY (id) -> p (id)
+    FILTER (WHERE c.id = ('1'::filter_coerce_io_type)::integer)
+)
+-- rejected, reason: CoerceViaIO output function is volatile
+JOIN filter_coerce_io_grandchild g FOR KEY (id) -> c (id);
+DROP TABLE filter_coerce_io_grandchild, filter_coerce_io_child,
+           filter_coerce_io_parent;
+DROP CAST (filter_coerce_io_type AS integer);
+SET client_min_messages = warning;
+DROP TYPE filter_coerce_io_type CASCADE;
+RESET client_min_messages;
+
+-- FILTER2: FK left join with FILTER — outer join rows still present
+SELECT * FROM t1
+-- accepted
+  LEFT JOIN t2 FOR KEY (c3) -> t1 (c1)
+  FILTER (WHERE t2.c4 = 10);
+
+-- FILTER3: USING join with FILTER clause is not supported
+CREATE TABLE tf1 (id int PRIMARY KEY, val int);
+CREATE TABLE tf2 (id int PRIMARY KEY, val int);
+INSERT INTO tf1 VALUES (1, 10), (2, 20), (3, 30);
+INSERT INTO tf2 VALUES (1, 100), (2, 200);
+
+-- rejected, reason: join-local FILTER is supported only for FOR KEY joins
+SELECT * FROM tf1 JOIN tf2 USING (id) FILTER (WHERE tf1.val > 10);
+
+-- rejected, reason: same restriction after a USING join alias
+SELECT * FROM tf1 JOIN tf2 USING (id) AS tfj FILTER (WHERE tf2.val > 100);
+
+-- Ordinary USING joins do not expose key-join facts.
+CREATE TABLE tf_child (id int NOT NULL REFERENCES tf1 (id));
+INSERT INTO tf_child VALUES (1), (2), (3);
+
+SELECT *
+FROM (SELECT id FROM tf2 JOIN tf1 USING (id)) q
+-- rejected, reason: the ordinary USING join exposes no surface facts
+JOIN tf_child c FOR KEY (id) -> q (id);
+
+DROP TABLE tf_child;
+
+-- Relabeled projected FK columns are not direct key columns.
+CREATE TABLE relabel_cast_parent (id text PRIMARY KEY);
+CREATE TABLE relabel_cast_child
+(
+    id text NOT NULL REFERENCES relabel_cast_parent (id)
+);
+INSERT INTO relabel_cast_parent VALUES ('a');
+INSERT INTO relabel_cast_child VALUES ('a');
+
+SELECT *
+FROM relabel_cast_parent p
+JOIN (
+    SELECT id::varchar AS id
+    FROM relabel_cast_child
+) q FOR KEY (id) -> p (id);
+
+DROP TABLE relabel_cast_child, relabel_cast_parent;
+
+-- Relabeled projected FK columns must preserve exact typmod identity.
+CREATE TABLE relabel_typmod_parent (id numeric(10,0) PRIMARY KEY);
+CREATE TABLE relabel_typmod_child
+(
+    id numeric(10,0) NOT NULL REFERENCES relabel_typmod_parent (id)
+);
+INSERT INTO relabel_typmod_parent VALUES (1);
+INSERT INTO relabel_typmod_child VALUES (1);
+
+SELECT *
+FROM relabel_typmod_parent p
+JOIN (
+    SELECT id::numeric AS id
+    FROM relabel_typmod_child
+) q FOR KEY (id) -> p (id);
+
+DROP TABLE relabel_typmod_child, relabel_typmod_parent;
+
+-- ORDER BY collation relabels do not change projected key identity.
+CREATE COLLATION relabel_key_join_c_copy FROM "C";
+CREATE TABLE relabel_collation_parent (id text COLLATE "C" PRIMARY KEY);
+CREATE TABLE relabel_collation_child
+(
+    id text COLLATE "C" NOT NULL REFERENCES relabel_collation_parent (id)
+);
+INSERT INTO relabel_collation_parent VALUES ('a');
+INSERT INTO relabel_collation_child VALUES ('a');
+
+SELECT *
+FROM relabel_collation_parent p
+JOIN (
+    SELECT id
+    FROM relabel_collation_child
+    ORDER BY id COLLATE relabel_key_join_c_copy
+) q FOR KEY (id) -> p (id);
+
+DROP TABLE relabel_collation_child, relabel_collation_parent;
+DROP COLLATION relabel_key_join_c_copy;
+
+-- Dropped columns in join inputs are ignored while projecting key facts.
+CREATE TABLE dropped_attr_parent (id int PRIMARY KEY, gone int);
+CREATE TABLE dropped_attr_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES dropped_attr_parent (id),
+    gone int
+);
+ALTER TABLE dropped_attr_parent DROP COLUMN gone;
+ALTER TABLE dropped_attr_child DROP COLUMN gone;
+INSERT INTO dropped_attr_parent VALUES (1);
+INSERT INTO dropped_attr_child VALUES (10, 1);
+
+SELECT count(*) AS dropped_attr_rows
+FROM dropped_attr_parent p
+JOIN dropped_attr_child c FOR KEY (parent_id) -> p (id);
+
+DROP TABLE dropped_attr_child, dropped_attr_parent;
+
+-- FILTER4: FILTER in deparsed view (round-trip test)
+CREATE VIEW v_filter AS
+-- accepted
+  SELECT * FROM t1 JOIN t2 FOR KEY (c3) -> t1 (c1) FILTER (WHERE t2.c4 = 10);
+SELECT pg_get_viewdef('v_filter');
+DROP VIEW v_filter;
+
+-- FILTER5: a filtered key-join result must not export unfiltered rowCoverage
+CREATE TABLE filter_parent (id int PRIMARY KEY);
+CREATE TABLE filter_bridge
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES filter_parent (id),
+    keep boolean NOT NULL
+);
+CREATE TABLE filter_detail
+(
+    id int PRIMARY KEY,
+    bridge_id int NOT NULL REFERENCES filter_bridge (id)
+);
+
+INSERT INTO filter_parent VALUES (1), (2);
+INSERT INTO filter_bridge VALUES (10, 1, true), (20, 2, false);
+INSERT INTO filter_detail VALUES (100, 10), (200, 20);
+
+SELECT d.id AS detail_id, q.bridge_id
+FROM filter_detail d
+JOIN (
+    SELECT b.id AS bridge_id
+    FROM filter_parent p
+    JOIN filter_bridge b FOR KEY (parent_id) -> p (id)
+      FILTER (WHERE b.keep)
+) q FOR KEY (bridge_id) <- d (bridge_id);
+
+SELECT d.id AS detail_id, q.bridge_id
+FROM filter_detail d
+JOIN (
+    SELECT b.id AS bridge_id
+    FROM filter_parent p
+    JOIN filter_bridge b FOR KEY (parent_id) -> p (id)
+      FILTER (WHERE b.id = 10)
+) q FOR KEY (bridge_id) <- d (bridge_id);
+
+DROP TABLE filter_detail, filter_bridge, filter_parent;
+
+-- FILTER5a: referencing-side uniqueness must exactly match the FK key.
+CREATE TABLE filter_unique_len_parent (id int PRIMARY KEY);
+CREATE TABLE filter_unique_len_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES filter_unique_len_parent (id),
+    tag int NOT NULL,
+    UNIQUE (parent_id, tag)
+);
+INSERT INTO filter_unique_len_parent VALUES (1), (2);
+INSERT INTO filter_unique_len_child VALUES (10, 1, 1), (20, 2, 1);
+
+SELECT count(*) AS unique_len_rows
+FROM filter_unique_len_parent p
+JOIN filter_unique_len_child c FOR KEY (parent_id) -> p (id);
+
+DROP TABLE filter_unique_len_child, filter_unique_len_parent;
+
+CREATE TABLE filter_unique_identity_parent (code text COLLATE "C" PRIMARY KEY);
+CREATE TABLE filter_unique_identity_child
+(
+    code text COLLATE "C" NOT NULL
+        REFERENCES filter_unique_identity_parent (code),
+    id int PRIMARY KEY
+);
+CREATE UNIQUE INDEX filter_unique_identity_child_code_idx
+    ON filter_unique_identity_child (code COLLATE "POSIX");
+INSERT INTO filter_unique_identity_parent VALUES ('a'), ('b');
+INSERT INTO filter_unique_identity_child VALUES ('a', 1), ('b', 2);
+
+SELECT count(*) AS unique_identity_rows
+FROM filter_unique_identity_parent p
+JOIN filter_unique_identity_child c FOR KEY (code) -> p (code);
+
+DROP TABLE filter_unique_identity_child, filter_unique_identity_parent;
+
+CREATE TABLE filter_sibling_fk_grand (id int PRIMARY KEY);
+CREATE TABLE filter_sibling_fk_parent
+(
+    id int PRIMARY KEY,
+    grand_id int NOT NULL REFERENCES filter_sibling_fk_grand (id)
+);
+CREATE TABLE filter_sibling_fk_child
+(
+    parent_id int NOT NULL UNIQUE REFERENCES filter_sibling_fk_parent (id),
+    payload int PRIMARY KEY
+);
+INSERT INTO filter_sibling_fk_grand VALUES (1), (2);
+INSERT INTO filter_sibling_fk_parent VALUES (1, 1), (2, 2);
+INSERT INTO filter_sibling_fk_child VALUES (1, 10), (2, 20);
+
+SELECT count(*) AS sibling_fk_rows
+FROM (
+    SELECT c1.parent_id AS c1_parent, c2.parent_id AS c2_parent
+    FROM filter_sibling_fk_parent p
+    LEFT JOIN filter_sibling_fk_child c1
+        FOR KEY (parent_id) -> p (id)
+    LEFT JOIN filter_sibling_fk_child c2
+        FOR KEY (parent_id) -> p (id)
+) q
+LEFT JOIN filter_sibling_fk_parent p2
+    FOR KEY (id) <- q (c1_parent)
+LEFT JOIN filter_sibling_fk_parent p3
+    FOR KEY (id) <- q (c2_parent);
+
+SELECT count(*) AS sibling_filtered_fk_rows
+FROM (
+    SELECT c1.parent_id AS c1_parent, c2.parent_id AS c2_parent
+    FROM filter_sibling_fk_parent p
+    LEFT JOIN filter_sibling_fk_child c1
+        FOR KEY (parent_id) -> p (id)
+    LEFT JOIN filter_sibling_fk_child c2
+        FOR KEY (parent_id) -> p (id)
+        FILTER (WHERE c2.parent_id = 1)
+) q
+LEFT JOIN filter_sibling_fk_parent p2
+    FOR KEY (id) <- q (c1_parent)
+LEFT JOIN filter_sibling_fk_parent p3
+    FOR KEY (id) <- q (c2_parent);
+
+SELECT count(*) AS sibling_unrelated_target_rows
+FROM (
+    SELECT c1.parent_id AS c1_parent, c2.parent_id AS c2_parent
+    FROM filter_sibling_fk_parent p
+    LEFT JOIN filter_sibling_fk_child c1
+        FOR KEY (parent_id) -> p (id)
+    LEFT JOIN filter_sibling_fk_child c2
+        FOR KEY (parent_id) -> p (id)
+        FILTER (WHERE c2.parent_id = 1)
+) q
+LEFT JOIN filter_sibling_fk_parent p2
+    FOR KEY (id) <- q (c1_parent)
+LEFT JOIN filter_sibling_fk_parent p3
+    FOR KEY (id) <- q (c2_parent)
+LEFT JOIN filter_sibling_fk_grand g
+    FOR KEY (id) <- p3 (grand_id);
+
+DROP TABLE filter_sibling_fk_child, filter_sibling_fk_parent,
+    filter_sibling_fk_grand;
+
+-- FILTER5b: matched-filter evidence must not propagate to unrelated FK facts
+CREATE TABLE filter_crossprop_a (aid int PRIMARY KEY);
+CREATE TABLE filter_crossprop_c (cid int PRIMARY KEY);
+CREATE TABLE filter_crossprop_b
+(
+    c_id int NOT NULL REFERENCES filter_crossprop_c (cid),
+    a_id int NOT NULL UNIQUE REFERENCES filter_crossprop_a (aid),
+    bid int PRIMARY KEY
+);
+CREATE TABLE filter_crossprop_r
+(
+    r_aid int NOT NULL REFERENCES filter_crossprop_a (aid),
+    rid int PRIMARY KEY
+);
+
+INSERT INTO filter_crossprop_a VALUES (1);
+INSERT INTO filter_crossprop_c VALUES (1), (2);
+INSERT INTO filter_crossprop_b VALUES (2, 1, 10);
+INSERT INTO filter_crossprop_r VALUES (1, 100);
+
+CREATE VIEW filter_crossprop_r_filtered AS
+SELECT r_aid, rid FROM filter_crossprop_r WHERE r_aid = 1::int;
+
+CREATE VIEW filter_crossprop_c_filtered AS
+SELECT cid FROM filter_crossprop_c WHERE cid = 1::int;
+
+WITH q AS (
+    SELECT ab.aid, ab.c_id, rf.rid
+    FROM (
+        SELECT a.aid, b.c_id, b.bid
+        FROM filter_crossprop_a a
+        LEFT JOIN filter_crossprop_b b FOR KEY (a_id) -> a (aid)
+    ) ab
+    JOIN filter_crossprop_r_filtered rf FOR KEY (r_aid) -> ab (aid)
+)
+SELECT q.aid, q.c_id, cf.cid AS matched_cid
+FROM q
+LEFT JOIN filter_crossprop_c_filtered cf FOR KEY (cid) <- q (c_id);
+
+DROP VIEW filter_crossprop_c_filtered, filter_crossprop_r_filtered;
+DROP TABLE filter_crossprop_r, filter_crossprop_b,
+           filter_crossprop_c, filter_crossprop_a;
+
+-- FILTER5c: boolean proof-filter values must be simple expressions
+CREATE TABLE filter_bool_parent (id boolean PRIMARY KEY);
+CREATE TABLE filter_bool_child
+(
+    parent_id boolean NOT NULL REFERENCES filter_bool_parent (id)
+);
+INSERT INTO filter_bool_parent VALUES (true), (false);
+INSERT INTO filter_bool_child VALUES (true);
+
+CREATE VIEW filter_bool_parent_or AS
+SELECT id FROM filter_bool_parent WHERE id = (true OR false);
+CREATE VIEW filter_bool_child_or AS
+SELECT parent_id FROM filter_bool_child WHERE parent_id = (true OR false);
+
+SELECT *
+FROM filter_bool_parent_or p
+-- rejected, reason: BoolExpr filter values are not proof-filter terms
+JOIN filter_bool_child_or c FOR KEY (parent_id) -> p (id);
+
+CREATE VIEW filter_bool_parent_test AS
+SELECT id FROM filter_bool_parent WHERE id = (true IS TRUE);
+CREATE VIEW filter_bool_child_test AS
+SELECT parent_id FROM filter_bool_child WHERE parent_id = (true IS TRUE);
+
+SELECT *
+FROM filter_bool_parent_test p
+-- rejected, reason: BooleanTest filter values are not proof-filter terms
+JOIN filter_bool_child_test c FOR KEY (parent_id) -> p (id);
+
+DROP VIEW filter_bool_child_test, filter_bool_parent_test,
+          filter_bool_child_or, filter_bool_parent_or;
+DROP TABLE filter_bool_child, filter_bool_parent;
+
+-- FILTER5d: pseudo-function filter values are not proof-filter terms
+CREATE TABLE filter_pseudo_parent (id int PRIMARY KEY);
+CREATE TABLE filter_pseudo_child
+(
+    parent_id int NOT NULL REFERENCES filter_pseudo_parent (id)
+);
+INSERT INTO filter_pseudo_parent VALUES (1), (2);
+INSERT INTO filter_pseudo_child VALUES (1);
+
+CREATE VIEW filter_case_parent_v AS
+SELECT id FROM filter_pseudo_parent
+WHERE id = CASE WHEN true THEN 1 ELSE 2 END;
+CREATE VIEW filter_case_child_v AS
+SELECT parent_id FROM filter_pseudo_child
+WHERE parent_id = CASE WHEN true THEN 1 ELSE 2 END;
+SELECT *
+FROM filter_case_parent_v p
+-- rejected, reason: CaseExpr filter values are not proof-filter terms
+JOIN filter_case_child_v c FOR KEY (parent_id) -> p (id)
+ORDER BY p.id;
+DROP VIEW filter_case_child_v, filter_case_parent_v;
+
+CREATE VIEW filter_minmax_parent_v AS
+SELECT id FROM filter_pseudo_parent WHERE id = LEAST(1, 2);
+CREATE VIEW filter_minmax_child_v AS
+SELECT parent_id FROM filter_pseudo_child WHERE parent_id = LEAST(1, 2);
+SELECT count(*) AS minmax_filter_rows
+FROM filter_minmax_parent_v p
+-- rejected, reason: MinMaxExpr filter values are not proof-filter terms
+JOIN filter_minmax_child_v c FOR KEY (parent_id) -> p (id);
+DROP VIEW filter_minmax_child_v, filter_minmax_parent_v;
+
+CREATE VIEW filter_coalesce_parent_v AS
+SELECT id FROM filter_pseudo_parent WHERE id = COALESCE(1, 2);
+CREATE VIEW filter_coalesce_child_v AS
+SELECT parent_id FROM filter_pseudo_child WHERE parent_id = COALESCE(1, 2);
+SELECT count(*) AS coalesce_filter_rows
+FROM filter_coalesce_parent_v p
+-- rejected, reason: CoalesceExpr filter values are not proof-filter terms
+JOIN filter_coalesce_child_v c FOR KEY (parent_id) -> p (id);
+DROP VIEW filter_coalesce_child_v, filter_coalesce_parent_v;
+
+CREATE VIEW filter_nullif_parent_v AS
+SELECT id FROM filter_pseudo_parent WHERE id = NULLIF(1, 2);
+CREATE VIEW filter_nullif_child_v AS
+SELECT parent_id FROM filter_pseudo_child WHERE parent_id = NULLIF(1, 2);
+SELECT count(*) AS nullif_filter_rows
+FROM filter_nullif_parent_v p
+-- rejected, reason: NullIfExpr filter values are not proof-filter terms
+JOIN filter_nullif_child_v c FOR KEY (parent_id) -> p (id);
+DROP VIEW filter_nullif_child_v, filter_nullif_parent_v;
+
+DROP TABLE filter_pseudo_child, filter_pseudo_parent;
+
+-- FILTER5e: matched-filter propagation skips other same-relation keys
+CREATE TABLE filter_self_rowcov
+(
+    id int PRIMARY KEY,
+    parent_id int UNIQUE NOT NULL REFERENCES filter_self_rowcov (id)
+);
+CREATE TABLE filter_self_rowcov_ref
+(
+    parent_id int NOT NULL REFERENCES filter_self_rowcov (parent_id)
+);
+INSERT INTO filter_self_rowcov VALUES (1, 1), (2, 2);
+INSERT INTO filter_self_rowcov_ref VALUES (1);
+
+CREATE VIEW filter_self_rowcov_ref_v AS
+SELECT parent_id FROM filter_self_rowcov_ref WHERE parent_id = 1;
+
+SELECT count(*) AS self_rowcov_rows
+FROM (
+    SELECT c.parent_id
+    FROM filter_self_rowcov p
+    JOIN (
+        SELECT id, parent_id
+        FROM filter_self_rowcov
+        WHERE parent_id = 1
+    ) c FOR KEY (parent_id) -> p (id)
+) q
+JOIN filter_self_rowcov_ref_v r FOR KEY (parent_id) -> q (parent_id);
+
+DROP VIEW filter_self_rowcov_ref_v;
+DROP TABLE filter_self_rowcov_ref, filter_self_rowcov;
+
+-- FILTER5f: matched-filter propagation skips different key arities
+CREATE TABLE filter_len_rowcov
+(
+    id int UNIQUE NOT NULL,
+    a int NOT NULL,
+    b int NOT NULL,
+    parent_a int NOT NULL,
+    parent_b int NOT NULL,
+    UNIQUE (a, b),
+    UNIQUE (parent_a, parent_b),
+    FOREIGN KEY (parent_a, parent_b) REFERENCES filter_len_rowcov (a, b)
+);
+CREATE TABLE filter_len_ref
+(
+    parent_a int NOT NULL,
+    parent_b int NOT NULL,
+    FOREIGN KEY (parent_a, parent_b)
+        REFERENCES filter_len_rowcov (parent_a, parent_b)
+);
+INSERT INTO filter_len_rowcov
+VALUES (100, 1, 1, 1, 1), (200, 2, 2, 2, 2);
+INSERT INTO filter_len_ref VALUES (1, 1);
+
+CREATE VIEW filter_len_ref_v AS
+SELECT parent_a, parent_b
+FROM filter_len_ref
+WHERE parent_a = 1 AND parent_b = 1;
+
+SELECT count(*) AS length_mismatch_rows
+FROM (
+    SELECT c.parent_a, c.parent_b
+    FROM filter_len_rowcov p
+    JOIN (
+        SELECT id, a, b, parent_a, parent_b
+        FROM filter_len_rowcov
+        WHERE parent_a = 1 AND parent_b = 1
+    ) c FOR KEY (parent_a, parent_b) -> p (a, b)
+) q
+JOIN filter_len_ref_v r FOR KEY (parent_a, parent_b) -> q (parent_a, parent_b);
+
+DROP VIEW filter_len_ref_v;
+DROP TABLE filter_len_ref, filter_len_rowcov;
+
+-- FILTER5g: matched-filter propagation skips different key identities
+CREATE FUNCTION filter_identity_eq(a int, b int) RETURNS boolean
+LANGUAGE sql IMMUTABLE STRICT AS $$ SELECT a = b $$;
+
+CREATE OPERATOR ===
+(
+    LEFTARG = int,
+    RIGHTARG = int,
+    PROCEDURE = filter_identity_eq,
+    COMMUTATOR = ===,
+    RESTRICT = eqsel,
+    JOIN = eqjoinsel,
+    MERGES
+);
+
+CREATE OPERATOR CLASS filter_identity_int4_ops
+FOR TYPE int USING btree AS
+    OPERATOR 1 < (int, int),
+    OPERATOR 2 <= (int, int),
+    OPERATOR 3 === (int, int),
+    OPERATOR 4 >= (int, int),
+    OPERATOR 5 > (int, int),
+    FUNCTION 1 btint4cmp(int, int);
+
+CREATE TABLE filter_identity_grand (id int);
+CREATE UNIQUE INDEX filter_identity_grand_idx
+    ON filter_identity_grand USING btree (id filter_identity_int4_ops);
+CREATE TABLE filter_identity_parent
+(
+    id int PRIMARY KEY REFERENCES filter_identity_grand (id)
+);
+CREATE TABLE filter_identity_child
+(
+    parent_id int NOT NULL REFERENCES filter_identity_parent (id)
+);
+INSERT INTO filter_identity_grand VALUES (1), (2);
+INSERT INTO filter_identity_parent VALUES (1), (2);
+INSERT INTO filter_identity_child VALUES (1);
+
+CREATE VIEW filter_identity_child_v AS
+SELECT parent_id FROM filter_identity_child WHERE parent_id = 1;
+
+SELECT count(*) AS identity_mismatch_rows
+FROM (
+    SELECT p.id
+    FROM filter_identity_parent p
+    JOIN filter_identity_child_v c FOR KEY (parent_id) -> p (id)
+) q
+JOIN filter_identity_grand g FOR KEY (id) <- q (id);
+
+DROP VIEW filter_identity_child_v;
+DROP TABLE filter_identity_child, filter_identity_parent, filter_identity_grand;
+DROP OPERATOR CLASS filter_identity_int4_ops USING btree;
+DROP OPERATOR FAMILY filter_identity_int4_ops USING btree;
+DROP OPERATOR === (int, int);
+DROP FUNCTION filter_identity_eq(int, int);
+
+DROP TABLE tf1, tf2;
+
+-- FILTER6: key join predicate uses the FK equality operator, not search_path "="
+CREATE SCHEMA key_join_shadow;
+SET search_path = key_join_shadow, pg_catalog;
+
+CREATE FUNCTION int4_always_true(int4, int4) RETURNS boolean
+LANGUAGE sql IMMUTABLE STRICT AS $$ SELECT true $$;
+
+CREATE OPERATOR = (
+  LEFTARG = int4,
+  RIGHTARG = int4,
+  FUNCTION = int4_always_true,
+  COMMUTATOR = OPERATOR(key_join_shadow.=)
+);
+
+CREATE TABLE shadow_parent (id int PRIMARY KEY);
+CREATE TABLE shadow_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES shadow_parent (id)
+);
+
+INSERT INTO shadow_parent VALUES (1), (2);
+INSERT INTO shadow_child VALUES (10, 1);
+
+SELECT p.id AS parent_id, c.id AS child_id, c.parent_id AS child_parent_id
+FROM shadow_parent p
+-- accepted
+JOIN shadow_child c FOR KEY (parent_id) -> p (id)
+ORDER BY p.id, c.id;
+
+CREATE VIEW shadow_parent_filter AS
+SELECT id FROM shadow_parent WHERE id = 1;
+
+CREATE VIEW shadow_child_filter AS
+SELECT parent_id FROM shadow_child WHERE parent_id = 1;
+
+-- rejected, reason: filter "=" is not the exact key equality operator
+SELECT *
+FROM shadow_parent_filter p
+JOIN shadow_child_filter c FOR KEY (parent_id) -> p (id);
+
+DROP VIEW shadow_child_filter, shadow_parent_filter;
+DROP TABLE shadow_child, shadow_parent;
+DROP OPERATOR = (int4, int4);
+DROP FUNCTION int4_always_true(int4, int4);
+DROP SCHEMA key_join_shadow;
+RESET search_path;
+
+-- FILTER7: matched filters may use compatible cross-type equality
+CREATE TABLE filter_opfamily_parent
+(
+    tenant_id bigint NOT NULL,
+    id bigint NOT NULL,
+    PRIMARY KEY (tenant_id, id)
+);
+CREATE TABLE filter_opfamily_child
+(
+    tenant_id bigint NOT NULL,
+    parent_id bigint NOT NULL,
+    FOREIGN KEY (tenant_id, parent_id)
+        REFERENCES filter_opfamily_parent (tenant_id, id)
+);
+
+INSERT INTO filter_opfamily_parent VALUES (1, 10), (2, 20);
+INSERT INTO filter_opfamily_child VALUES (1, 10), (2, 20);
+
+CREATE VIEW filter_opfamily_parent_small AS
+SELECT tenant_id, id
+FROM filter_opfamily_parent
+WHERE tenant_id = 1::smallint;
+
+CREATE VIEW filter_opfamily_child_small AS
+SELECT tenant_id, parent_id
+FROM filter_opfamily_child
+WHERE tenant_id = 1::smallint;
+
+CREATE VIEW filter_opfamily_child_big AS
+SELECT tenant_id, parent_id
+FROM filter_opfamily_child
+WHERE tenant_id = 1::bigint;
+
+CREATE VIEW filter_opfamily_parent_big AS
+SELECT tenant_id, id
+FROM filter_opfamily_parent
+WHERE tenant_id = 1::bigint;
+
+CREATE VIEW filter_opfamily_parent_int AS
+SELECT tenant_id, id
+FROM filter_opfamily_parent
+WHERE tenant_id = 1::integer;
+
+CREATE VIEW filter_opfamily_child_int AS
+SELECT tenant_id, parent_id
+FROM filter_opfamily_child
+WHERE tenant_id = 1::integer;
+
+CREATE VIEW filter_opfamily_parent_right_big AS
+SELECT tenant_id, id
+FROM filter_opfamily_parent
+WHERE 1::bigint = tenant_id;
+
+-- accepted, reason: both filters use the same compatible bigint = smallint equality
+SELECT *
+FROM filter_opfamily_parent_small p
+JOIN filter_opfamily_child_small c FOR KEY (tenant_id, parent_id) -> p (tenant_id, id)
+ORDER BY p.tenant_id, p.id;
+
+-- accepted, reason: both filters use exact canonical tenant_id = 1::bigint
+SELECT *
+FROM filter_opfamily_parent_big p
+JOIN filter_opfamily_child_big c FOR KEY (tenant_id, parent_id) -> p (tenant_id, id)
+ORDER BY p.tenant_id, p.id;
+
+-- rejected, reason: filter value trees do not match exactly
+SELECT *
+FROM filter_opfamily_parent_small p
+JOIN filter_opfamily_child_big c FOR KEY (tenant_id, parent_id) -> p (tenant_id, id);
+
+-- accepted, reason: both filters use the same compatible bigint = integer equality
+SELECT *
+FROM filter_opfamily_parent_int p
+JOIN filter_opfamily_child_int c FOR KEY (tenant_id, parent_id) -> p (tenant_id, id);
+
+-- rejected, reason: key-on-right filters are not canonical proof evidence
+SELECT *
+FROM filter_opfamily_parent_right_big p
+JOIN filter_opfamily_child_big c FOR KEY (tenant_id, parent_id) -> p (tenant_id, id)
+ORDER BY p.tenant_id, p.id;
+
+DROP VIEW filter_opfamily_parent_right_big, filter_opfamily_child_int,
+          filter_opfamily_parent_int, filter_opfamily_parent_big,
+          filter_opfamily_child_big,
+          filter_opfamily_child_small, filter_opfamily_parent_small;
+DROP TABLE filter_opfamily_child, filter_opfamily_parent;
+
+CREATE TABLE filter_opfamily_cross_parent
+(
+    tenant_id bigint NOT NULL,
+    id bigint NOT NULL,
+    PRIMARY KEY (tenant_id, id)
+);
+CREATE TABLE filter_opfamily_cross_child
+(
+    tenant_id integer NOT NULL,
+    parent_id bigint NOT NULL,
+    FOREIGN KEY (tenant_id, parent_id)
+        REFERENCES filter_opfamily_cross_parent (tenant_id, id)
+);
+
+INSERT INTO filter_opfamily_cross_parent VALUES (1, 10), (2, 20);
+INSERT INTO filter_opfamily_cross_child VALUES (1, 10), (2, 20);
+
+CREATE VIEW filter_opfamily_cross_parent_small AS
+SELECT tenant_id, id
+FROM filter_opfamily_cross_parent
+WHERE tenant_id = 1::smallint;
+
+CREATE VIEW filter_opfamily_cross_child_small AS
+SELECT tenant_id, parent_id
+FROM filter_opfamily_cross_child
+WHERE tenant_id = 1::smallint;
+
+-- rejected, reason: FK and PK key types differ
+SELECT *
+FROM filter_opfamily_cross_parent_small p
+JOIN filter_opfamily_cross_child_small c FOR KEY (tenant_id, parent_id) -> p (tenant_id, id)
+ORDER BY p.tenant_id, p.id;
+
+DROP VIEW filter_opfamily_cross_child_small,
+          filter_opfamily_cross_parent_small;
+DROP TABLE filter_opfamily_cross_child, filter_opfamily_cross_parent;
+
+-- FILTER8: matched filters preserve value typmods structurally
+CREATE TABLE filter_typmod_parent
+(
+    amount numeric(10,2) PRIMARY KEY
+);
+CREATE TABLE filter_typmod_child
+(
+    amount numeric(10,2) NOT NULL REFERENCES filter_typmod_parent (amount)
+);
+
+INSERT INTO filter_typmod_parent VALUES (1.00), (2.00);
+INSERT INTO filter_typmod_child VALUES (1.00), (2.00);
+
+CREATE VIEW filter_typmod_parent_short AS
+SELECT amount FROM filter_typmod_parent
+WHERE amount = 1.00::numeric(5,2);
+
+CREATE VIEW filter_typmod_child_short AS
+SELECT amount FROM filter_typmod_child
+WHERE amount = 1.00::numeric(5,2);
+
+CREATE VIEW filter_typmod_parent_exact AS
+SELECT amount FROM filter_typmod_parent
+WHERE amount = 1.00::numeric(10,2);
+
+CREATE VIEW filter_typmod_child_exact AS
+SELECT amount FROM filter_typmod_child
+WHERE amount = 1.00::numeric(10,2);
+
+-- accepted, reason: both filters use identical value expression typmods
+SELECT *
+FROM filter_typmod_parent_short p
+JOIN filter_typmod_child_short c FOR KEY (amount) -> p (amount);
+
+-- accepted, reason: both filters use identical key value typmods
+SELECT *
+FROM filter_typmod_parent_exact p
+JOIN filter_typmod_child_exact c FOR KEY (amount) -> p (amount)
+ORDER BY p.amount;
+
+DROP VIEW filter_typmod_child_exact, filter_typmod_parent_exact,
+          filter_typmod_child_short, filter_typmod_parent_short;
+DROP TABLE filter_typmod_child, filter_typmod_parent;
+
+-- FILTER9: matched filters may use compatible deterministic collations
+CREATE TABLE filter_collation_parent
+(
+    code text COLLATE "C" PRIMARY KEY
+);
+CREATE TABLE filter_collation_child
+(
+    code text COLLATE "C" NOT NULL
+        REFERENCES filter_collation_parent (code)
+);
+
+INSERT INTO filter_collation_parent VALUES ('a'), ('b');
+INSERT INTO filter_collation_child VALUES ('a'), ('b');
+
+CREATE VIEW filter_collation_parent_posix AS
+SELECT code FROM filter_collation_parent
+WHERE code = 'a'::text COLLATE "POSIX";
+
+CREATE VIEW filter_collation_child_posix AS
+SELECT code FROM filter_collation_child
+WHERE code = 'a'::text COLLATE "POSIX";
+
+CREATE VIEW filter_collation_parent_exact AS
+SELECT code FROM filter_collation_parent
+WHERE code = 'a'::text COLLATE "C";
+
+CREATE VIEW filter_collation_child_exact AS
+SELECT code FROM filter_collation_child
+WHERE code = 'a'::text COLLATE "C";
+
+-- accepted, reason: deterministic collations agree on equality
+SELECT *
+FROM filter_collation_parent_posix p
+JOIN filter_collation_child_posix c FOR KEY (code) -> p (code);
+
+-- accepted, reason: filter value collations exactly match the key collation
+SELECT *
+FROM filter_collation_parent_exact p
+JOIN filter_collation_child_exact c FOR KEY (code) -> p (code)
+ORDER BY p.code;
+
+-- Nondeterministic filter collations are exercised in key_join_icu.
+
+DROP VIEW filter_collation_child_exact, filter_collation_parent_exact,
+          filter_collation_child_posix, filter_collation_parent_posix;
+DROP TABLE filter_collation_child, filter_collation_parent;
+
+-- ============================================================
+-- Revalidation via stored Query (no text round-trip)
+-- ============================================================
+
+-- Set up tables and views for revalidation tests
+-- rv_child uses cid (not id) to avoid column name conflicts in SELECT *
+CREATE TABLE rv_base (id int PRIMARY KEY, v int);
+CREATE TABLE rv_child (cid int PRIMARY KEY, ref int NOT NULL REFERENCES rv_base(id));
+
+-- rv_parent initially exposes all rows of rv_base (rows_preserved = true)
+CREATE VIEW rv_parent AS SELECT id FROM rv_base;
+
+-- rv_dep FK-joins rv_child against rv_parent; valid initially
+CREATE VIEW rv_dep AS
+-- accepted
+    SELECT * FROM rv_parent JOIN rv_child FOR KEY (ref) -> rv_parent (id);
+
+-- Replace rv_parent with a filtered version — rv_dep should become invalid
+-- because the referenced relation no longer preserves all rows.
+-- rejected, reason: replacement would invalidate dependent key-join view
+CREATE OR REPLACE VIEW rv_parent AS SELECT id FROM rv_base WHERE v > 0;
+
+-- Replace rv_parent back to unfiltered — rv_dep becomes valid again
+CREATE OR REPLACE VIEW rv_parent AS SELECT id FROM rv_base;
+
+DROP VIEW rv_dep, rv_parent;
+DROP TABLE rv_child, rv_base;
+
+-- Stored revalidation failure should format multi-column key lists from the
+-- stored RTE attnums, not just live parse-time column arrays.
+CREATE TABLE rv_multi_parent
+(
+    tenant_id int NOT NULL,
+    id int NOT NULL,
+    v int,
+    PRIMARY KEY (tenant_id, id)
+);
+CREATE TABLE rv_multi_child
+(
+    cid int PRIMARY KEY,
+    tenant_id int NOT NULL,
+    ref int NOT NULL,
+    FOREIGN KEY (tenant_id, ref)
+        REFERENCES rv_multi_parent (tenant_id, id)
+);
+CREATE VIEW rv_multi_parent_v AS
+SELECT tenant_id, id FROM rv_multi_parent;
+CREATE VIEW rv_multi_dep AS
+-- accepted
+    SELECT p.tenant_id, p.id, c.cid
+    FROM rv_multi_parent_v p
+    JOIN rv_multi_child c FOR KEY (tenant_id, ref) -> p (tenant_id, id);
+-- rejected, reason: replacement invalidates a stored composite-key proof
+CREATE OR REPLACE VIEW rv_multi_parent_v AS
+SELECT tenant_id, id FROM rv_multi_parent WHERE v > 0;
+DROP VIEW rv_multi_dep, rv_multi_parent_v;
+DROP TABLE rv_multi_child, rv_multi_parent;
+
+-- Regression: no false positive when view has WHERE on referenced base table.
+-- A naive revalidation passing the stored query to analyze_join_tree would see
+-- jointree->quals != NULL and falsely conclude rows_preserved = false.
+CREATE TABLE rv3_ref (id int PRIMARY KEY);
+CREATE TABLE rv3_child (ref int NOT NULL REFERENCES rv3_ref(id));
+CREATE VIEW rv3_aux AS SELECT 1 AS x;      -- replaced to trigger revalidation
+
+CREATE VIEW rv3_dep AS
+    SELECT r.id, c.ref
+    FROM rv3_ref r
+-- accepted
+    JOIN rv3_child c FOR KEY (ref) -> r (id)
+    CROSS JOIN rv3_aux
+    WHERE r.id > 0;                         -- WHERE on referenced base table
+
+-- Replacing rv3_aux revalidates rv3_dep; must succeed (no false positive).
+CREATE OR REPLACE VIEW rv3_aux AS SELECT 2 AS x;
+
+DROP VIEW rv3_dep, rv3_aux;
+DROP TABLE rv3_child, rv3_ref;
+
+-- Stored filtered key joins must re-split and re-merge joinFilter quals.
+CREATE TABLE rv_filter_parent (id int PRIMARY KEY);
+CREATE TABLE rv_filter_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES rv_filter_parent (id)
+);
+INSERT INTO rv_filter_parent VALUES (1), (2);
+INSERT INTO rv_filter_child VALUES (10, 1), (20, 2);
+
+CREATE VIEW rv_filter_child_v AS
+SELECT c.parent_id
+FROM rv_filter_parent p
+JOIN rv_filter_child c FOR KEY (parent_id) -> p (id)
+  FILTER (WHERE c.parent_id = 1);
+
+SELECT count(*) AS rv_filter_rows
+FROM rv_filter_child_v v
+JOIN rv_filter_parent p FOR KEY (id) <- v (parent_id);
+
+DROP VIEW rv_filter_child_v;
+DROP TABLE rv_filter_child, rv_filter_parent;
+
+-- Stored view revalidation must resolve outer CTE references from nested subqueries.
+CREATE TABLE stored_cte_parent (id int PRIMARY KEY);
+CREATE TABLE stored_cte_one_child
+(
+    id int PRIMARY KEY,
+    pid int UNIQUE NOT NULL REFERENCES stored_cte_parent(id)
+);
+CREATE TABLE stored_cte_child
+(
+    id int PRIMARY KEY,
+    pid int NOT NULL REFERENCES stored_cte_parent(id)
+);
+INSERT INTO stored_cte_parent VALUES (1), (2);
+INSERT INTO stored_cte_one_child VALUES (10, 1), (20, 2);
+INSERT INTO stored_cte_child VALUES (100, 1), (101, 1), (200, 2);
+
+CREATE VIEW stored_cte_parent_v AS
+WITH p AS (SELECT id FROM stored_cte_parent)
+SELECT q.id, oc.id AS one_child_id
+FROM (SELECT id FROM p) q
+LEFT JOIN stored_cte_one_child oc FOR KEY (pid) -> q (id);
+
+SELECT v.id, c.id AS child_id
+FROM stored_cte_parent_v v
+-- accepted
+JOIN stored_cte_child c FOR KEY (pid) -> v (id)
+ORDER BY v.id, c.id;
+-- CTE lookup must consume ctelevelsup through the CTE owner's query stack, not
+-- through a nested RTE reference site.
+WITH p AS (SELECT id FROM stored_cte_parent)
+SELECT v.id, c.id AS child_id
+FROM (
+    WITH q AS (SELECT id FROM p),
+         p AS (SELECT id FROM stored_cte_parent WHERE id = 1)
+    SELECT s.id
+    FROM (SELECT id FROM q) s
+) v
+-- accepted, reason: q sees the outer p, not the later inner p
+JOIN stored_cte_child c FOR KEY (pid) -> v (id)
+ORDER BY v.id, c.id;
+
+-- Shadowed CTE names must resolve at the RTE's own ctelevelsup.
+CREATE VIEW stored_cte_shadow_v AS
+WITH p AS (SELECT id FROM stored_cte_parent)
+SELECT q.id
+FROM (
+    WITH p AS (SELECT id FROM stored_cte_parent WHERE id = 1)
+    SELECT id FROM p
+) q;
+
+SELECT v.id, c.id AS child_id
+FROM stored_cte_shadow_v v
+-- rejected, reason: inner shadowing CTE filters referenced rows
+JOIN stored_cte_child c FOR KEY (pid) -> v (id);
+
+DROP VIEW stored_cte_shadow_v, stored_cte_parent_v;
+DROP TABLE stored_cte_child, stored_cte_one_child, stored_cte_parent;
+
+-- Revalidation must not re-store dependent _RETURN rules.  A replacement that
+-- would need a different FK proof source is rejected until the dependent view
+-- is explicitly recreated.
+CREATE TABLE rv_rewrite_parent_a (id int PRIMARY KEY);
+CREATE TABLE rv_rewrite_parent_b (id int PRIMARY KEY);
+CREATE TABLE rv_rewrite_child
+(
+    cid int PRIMARY KEY,
+    ref int NOT NULL,
+    FOREIGN KEY (ref) REFERENCES rv_rewrite_parent_a (id),
+    FOREIGN KEY (ref) REFERENCES rv_rewrite_parent_b (id)
+);
+INSERT INTO rv_rewrite_parent_a VALUES (1);
+INSERT INTO rv_rewrite_parent_b VALUES (1);
+INSERT INTO rv_rewrite_child VALUES (10, 1);
+
+CREATE VIEW rv_rewrite_parent_v AS SELECT id FROM rv_rewrite_parent_a;
+CREATE VIEW rv_rewrite_child_v AS SELECT cid, ref FROM rv_rewrite_child;
+CREATE VIEW rv_rewrite_dep AS
+SELECT p.id AS parent_id, c.cid AS child_id
+FROM rv_rewrite_parent_v p
+-- accepted
+JOIN rv_rewrite_child_v c FOR KEY (ref) -> p (id);
+
+-- rejected, reason: replay would require a new proof dependency
+CREATE OR REPLACE VIEW rv_rewrite_parent_v AS SELECT id FROM rv_rewrite_parent_b;
+
+DROP VIEW rv_rewrite_dep;
+CREATE OR REPLACE VIEW rv_rewrite_parent_v AS SELECT id FROM rv_rewrite_parent_b;
+CREATE VIEW rv_rewrite_dep AS
+SELECT p.id AS parent_id, c.cid AS child_id
+FROM rv_rewrite_parent_v p
+-- accepted
+JOIN rv_rewrite_child_v c FOR KEY (ref) -> p (id);
+
+SELECT * FROM rv_rewrite_dep ORDER BY parent_id, child_id;
+
+DROP VIEW rv_rewrite_dep, rv_rewrite_child_v, rv_rewrite_parent_v;
+DROP TABLE rv_rewrite_child, rv_rewrite_parent_b, rv_rewrite_parent_a;
+
+-- Revalidation rejects proof switches that would rebuild executable key
+-- equality with a different operator.  Explicit recreation stores the new
+-- proof normally.
+CREATE FUNCTION key_join_bucket_cmp(a int, b int) RETURNS int
+LANGUAGE sql IMMUTABLE STRICT AS
+$$ SELECT CASE WHEN a / 10 < b / 10 THEN -1
+               WHEN a / 10 > b / 10 THEN 1
+               ELSE 0 END $$;
+
+CREATE FUNCTION key_join_bucket_eq(a int, b int) RETURNS boolean
+LANGUAGE sql IMMUTABLE STRICT AS $$ SELECT a / 10 = b / 10 $$;
+
+CREATE OPERATOR =# (
+    LEFTARG = int,
+    RIGHTARG = int,
+    FUNCTION = key_join_bucket_eq,
+    COMMUTATOR = =#,
+    RESTRICT = eqsel,
+    JOIN = eqjoinsel,
+    MERGES
+);
+
+CREATE OPERATOR CLASS key_join_bucket_int4_ops
+FOR TYPE int USING btree AS
+    OPERATOR 3 =# (int, int),
+    FUNCTION 1 key_join_bucket_cmp(int, int);
+
+CREATE TABLE rv_sound_parent_a (id int PRIMARY KEY);
+CREATE TABLE rv_sound_parent_b (id int);
+CREATE UNIQUE INDEX rv_sound_parent_b_idx
+    ON rv_sound_parent_b USING btree (id key_join_bucket_int4_ops);
+CREATE TABLE rv_sound_child
+(
+    cid int PRIMARY KEY,
+    pid int NOT NULL,
+    FOREIGN KEY (pid) REFERENCES rv_sound_parent_a (id),
+    FOREIGN KEY (pid) REFERENCES rv_sound_parent_b (id)
+);
+INSERT INTO rv_sound_parent_a VALUES (11);
+INSERT INTO rv_sound_parent_b VALUES (10);
+INSERT INTO rv_sound_child VALUES (1, 11);
+
+CREATE VIEW rv_sound_parent_v AS SELECT id FROM rv_sound_parent_a;
+CREATE VIEW rv_sound_child_v AS SELECT cid, pid FROM rv_sound_child;
+CREATE VIEW rv_sound_dep AS
+SELECT p.id AS parent_id, c.cid AS child_id, c.pid
+FROM rv_sound_parent_v p
+-- accepted
+JOIN rv_sound_child_v c FOR KEY (pid) -> p (id);
+
+-- rejected, reason: replay would change executable key equality
+CREATE OR REPLACE VIEW rv_sound_parent_v AS SELECT id FROM rv_sound_parent_b;
+
+SELECT * FROM rv_sound_dep ORDER BY parent_id, child_id;
+
+DROP VIEW rv_sound_dep;
+CREATE OR REPLACE VIEW rv_sound_parent_v AS SELECT id FROM rv_sound_parent_b;
+CREATE VIEW rv_sound_dep AS
+SELECT p.id AS parent_id, c.cid AS child_id, c.pid
+FROM rv_sound_parent_v p
+-- accepted
+JOIN rv_sound_child_v c FOR KEY (pid) -> p (id);
+SELECT * FROM rv_sound_dep ORDER BY parent_id, child_id;
+
+DROP VIEW rv_sound_dep;
+CREATE OR REPLACE VIEW rv_sound_parent_v AS SELECT id FROM rv_sound_parent_a;
+
+-- Non-_RETURN rule actions are only validated; reject a proof switch that
+-- would require persisting a rebuilt action tree.
+CREATE TABLE rv_sound_rule_event (id int);
+CREATE RULE rv_sound_rule_action AS ON DELETE TO rv_sound_rule_event
+DO ALSO SELECT p.id AS parent_id, c.cid AS child_id, c.pid
+FROM rv_sound_parent_v p
+-- accepted
+JOIN rv_sound_child_v c FOR KEY (pid) -> p (id);
+
+-- rejected, reason: non-_RETURN rule action would need rewritten key quals
+CREATE OR REPLACE VIEW rv_sound_parent_v AS SELECT id FROM rv_sound_parent_b;
+
+DROP RULE rv_sound_rule_action ON rv_sound_rule_event;
+DROP VIEW rv_sound_child_v, rv_sound_parent_v;
+DROP TABLE rv_sound_rule_event, rv_sound_child, rv_sound_parent_b, rv_sound_parent_a;
+
+DROP OPERATOR CLASS key_join_bucket_int4_ops USING btree;
+DROP OPERATOR FAMILY key_join_bucket_int4_ops USING btree;
+DROP OPERATOR =# (int, int);
+DROP FUNCTION key_join_bucket_cmp(int, int);
+DROP FUNCTION key_join_bucket_eq(int, int);
+
+-- Valid replacement changes facts that are recomputed for dependent views.
+CREATE TABLE rv_refresh_parent
+(
+    tenant_id int NOT NULL,
+    id int NOT NULL,
+    PRIMARY KEY (tenant_id, id)
+);
+CREATE TABLE rv_refresh_child
+(
+    tenant_id int NOT NULL,
+    parent_id int NOT NULL,
+    UNIQUE (tenant_id, parent_id),
+    FOREIGN KEY (tenant_id, parent_id)
+        REFERENCES rv_refresh_parent (tenant_id, id)
+);
+CREATE TABLE rv_refresh_reader
+(
+    tenant_id int NOT NULL,
+    parent_id int NOT NULL,
+    FOREIGN KEY (tenant_id, parent_id)
+        REFERENCES rv_refresh_parent (tenant_id, id)
+);
+
+CREATE VIEW rv_refresh_parent_v AS
+SELECT tenant_id, id FROM rv_refresh_parent;
+CREATE VIEW rv_refresh_child_v AS
+SELECT tenant_id, parent_id FROM rv_refresh_child WHERE tenant_id = 1;
+
+CREATE VIEW rv_refresh_dep AS
+SELECT p.tenant_id, p.id
+FROM rv_refresh_parent_v p
+-- accepted
+LEFT JOIN rv_refresh_child_v c FOR KEY (tenant_id, parent_id) -> p (tenant_id, id);
+
+-- This replacement remains valid for rv_refresh_dep, but recomputing its
+-- output facts now yields conditional row coverage.
+CREATE OR REPLACE VIEW rv_refresh_parent_v AS
+SELECT tenant_id, id FROM rv_refresh_parent WHERE tenant_id = 1;
+
+-- rejected, reason: recomputed dependent facts now require the tenant filter
+SELECT *
+FROM rv_refresh_reader r
+JOIN rv_refresh_dep d FOR KEY (tenant_id, id) <- r (tenant_id, parent_id);
+
+-- accepted, reason: referencing-side tenant filter matches the recomputed fact
+SELECT *
+FROM (SELECT * FROM rv_refresh_reader WHERE tenant_id = 1) r
+JOIN rv_refresh_dep d FOR KEY (tenant_id, id) <- r (tenant_id, parent_id);
+
+DROP VIEW rv_refresh_dep, rv_refresh_child_v, rv_refresh_parent_v;
+DROP TABLE rv_refresh_reader, rv_refresh_child, rv_refresh_parent;
+
+-- Stored key joins activate FK provenance from producer views.  The producer
+-- view alone does not block dropping the FK.
+CREATE TABLE ondemand_fk_parent
+(
+    id int PRIMARY KEY
+);
+CREATE TABLE ondemand_fk_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL,
+    CONSTRAINT ondemand_fk_child_parent_id_fkey
+        FOREIGN KEY (parent_id) REFERENCES ondemand_fk_parent (id)
+);
+CREATE VIEW ondemand_fk_child_v AS
+SELECT id, parent_id FROM ondemand_fk_child;
+ALTER TABLE ondemand_fk_child DROP CONSTRAINT ondemand_fk_child_parent_id_fkey;
+ALTER TABLE ondemand_fk_child ADD CONSTRAINT ondemand_fk_child_parent_id_fkey
+    FOREIGN KEY (parent_id) REFERENCES ondemand_fk_parent (id);
+CREATE VIEW ondemand_fk_consumer AS
+SELECT p.id AS parent_id, c.id AS child_id, c.parent_id AS child_parent_id
+FROM ondemand_fk_parent p
+-- accepted
+JOIN ondemand_fk_child_v c FOR KEY (parent_id) -> p (id);
+ALTER TABLE ondemand_fk_child DROP CONSTRAINT ondemand_fk_child_parent_id_fkey; -- rejected, reason: consumer view depends on the FK proof
+DROP VIEW ondemand_fk_consumer;
+ALTER TABLE ondemand_fk_child DROP CONSTRAINT ondemand_fk_child_parent_id_fkey;
+SELECT *
+FROM ondemand_fk_parent p
+-- rejected, reason: view facts are recomputed after the FK constraint was dropped
+JOIN ondemand_fk_child_v c FOR KEY (parent_id) -> p (id);
+DROP VIEW ondemand_fk_child_v;
+DROP TABLE ondemand_fk_child, ondemand_fk_parent;
+
+-- Revalidation may accept dependency shrinkage without rewriting a stored
+-- dependent view.
+CREATE TABLE rv_shrink_parent
+(
+    id int PRIMARY KEY
+);
+CREATE TABLE rv_shrink_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES rv_shrink_parent (id)
+);
+CREATE TABLE rv_shrink_one_child
+(
+    id int PRIMARY KEY,
+    parent_id int UNIQUE NOT NULL REFERENCES rv_shrink_parent (id)
+);
+INSERT INTO rv_shrink_parent VALUES (1), (2);
+INSERT INTO rv_shrink_child VALUES (10, 1), (20, 2);
+INSERT INTO rv_shrink_one_child VALUES (100, 1);
+
+CREATE VIEW rv_shrink_parent_v AS
+SELECT p.id
+FROM rv_shrink_parent p
+LEFT JOIN rv_shrink_one_child oc FOR KEY (parent_id) -> p (id);
+
+CREATE VIEW rv_shrink_dep AS
+SELECT p.id AS parent_id
+FROM rv_shrink_parent_v p
+JOIN rv_shrink_child c FOR KEY (parent_id) -> p (id);
+
+CREATE OR REPLACE VIEW rv_shrink_parent_v AS
+SELECT id FROM rv_shrink_parent;
+
+SELECT count(*) AS rv_shrink_rows FROM rv_shrink_dep;
+
+DROP VIEW rv_shrink_dep, rv_shrink_parent_v;
+DROP TABLE rv_shrink_one_child, rv_shrink_child, rv_shrink_parent;
+
+-- Revalidation must reject a replay that would require a new not-null
+-- proof dependency.
+CREATE TABLE rv_new_nn_parent
+(
+    id int PRIMARY KEY
+);
+CREATE TABLE rv_new_nn_child_a
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES rv_new_nn_parent (id)
+);
+CREATE TABLE rv_new_nn_child_b
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES rv_new_nn_parent (id)
+);
+INSERT INTO rv_new_nn_parent VALUES (1), (2);
+INSERT INTO rv_new_nn_child_a VALUES (10, 1);
+INSERT INTO rv_new_nn_child_b VALUES (20, 2);
+
+CREATE VIEW rv_new_nn_child_v AS
+SELECT id, parent_id FROM rv_new_nn_child_a;
+
+CREATE VIEW rv_new_nn_dep AS
+SELECT c.id AS child_id, c.parent_id
+FROM rv_new_nn_parent p
+JOIN rv_new_nn_child_v c FOR KEY (parent_id) -> p (id);
+
+-- rejected, reason: replay would require child_b's NOT NULL proof
+CREATE OR REPLACE VIEW rv_new_nn_child_v AS
+SELECT id, parent_id FROM rv_new_nn_child_b;
+
+SELECT * FROM rv_new_nn_dep ORDER BY child_id;
+
+DROP VIEW rv_new_nn_dep, rv_new_nn_child_v;
+DROP TABLE rv_new_nn_child_b, rv_new_nn_child_a, rv_new_nn_parent;
+
+-- Stored key joins must be revalidated when FK proof flags change.
+CREATE TABLE alter_fk_parent
+(
+    id int PRIMARY KEY
+);
+CREATE TABLE alter_fk_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL,
+    CONSTRAINT alter_fk_child_parent_id_fkey
+        FOREIGN KEY (parent_id) REFERENCES alter_fk_parent (id)
+);
+INSERT INTO alter_fk_parent VALUES (1);
+INSERT INTO alter_fk_child VALUES (10, 1);
+
+CREATE VIEW alter_fk_consumer AS
+SELECT p.id AS parent_id, c.id AS child_id
+FROM alter_fk_parent p
+-- accepted
+JOIN alter_fk_child c FOR KEY (parent_id) -> p (id);
+
+-- rejected, reason: stored view depends on the FK proof
+ALTER TABLE alter_fk_child
+    ALTER CONSTRAINT alter_fk_child_parent_id_fkey NOT ENFORCED;
+SELECT * FROM alter_fk_consumer ORDER BY child_id;
+-- rejected, reason: stored view depends on the non-deferrable FK proof
+ALTER TABLE alter_fk_child
+    ALTER CONSTRAINT alter_fk_child_parent_id_fkey
+    DEFERRABLE INITIALLY IMMEDIATE;
+DROP VIEW alter_fk_consumer;
+
+CREATE VIEW alter_fk_scalar_consumer AS
+SELECT (
+    SELECT count(*)
+    FROM alter_fk_parent p
+    -- accepted
+    JOIN alter_fk_child c FOR KEY (parent_id) -> p (id)
+) AS key_join_rows;
+-- rejected, reason: dependency scanning sees the scalar subquery proof
+ALTER TABLE alter_fk_child DROP CONSTRAINT alter_fk_child_parent_id_fkey;
+-- rejected, reason: stored scalar subquery proof depends on the FK proof
+ALTER TABLE alter_fk_child
+    ALTER CONSTRAINT alter_fk_child_parent_id_fkey NOT ENFORCED;
+DROP VIEW alter_fk_scalar_consumer;
+
+ALTER TABLE alter_fk_child
+    ALTER CONSTRAINT alter_fk_child_parent_id_fkey NOT ENFORCED;
+SELECT p.id AS parent_id, c.id AS child_id
+FROM alter_fk_parent p
+-- rejected, reason: FK is now not enforced
+JOIN alter_fk_child c FOR KEY (parent_id) -> p (id);
+DROP TABLE alter_fk_child, alter_fk_parent;
+
+-- Key join follows PostgreSQL's catalog-level FK validity, not current
+-- RI trigger firing state.
+CREATE TABLE trigger_bypass_parent (id int PRIMARY KEY);
+CREATE TABLE trigger_bypass_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL,
+    CONSTRAINT trigger_bypass_child_parent_id_fkey
+        FOREIGN KEY (parent_id) REFERENCES trigger_bypass_parent (id)
+);
+INSERT INTO trigger_bypass_parent VALUES (1);
+INSERT INTO trigger_bypass_child VALUES (10, 1);
+ALTER TABLE trigger_bypass_child DISABLE TRIGGER ALL;
+ALTER TABLE trigger_bypass_parent DISABLE TRIGGER ALL;
+SELECT count(*) AS key_join_rows
+FROM trigger_bypass_parent p
+JOIN trigger_bypass_child c FOR KEY (parent_id) -> p (id);
+ALTER TABLE trigger_bypass_child ENABLE TRIGGER ALL;
+ALTER TABLE trigger_bypass_parent ENABLE TRIGGER ALL;
+DROP TABLE trigger_bypass_child, trigger_bypass_parent;
+
+-- session_replication_role also does not affect key join proof validity.
+CREATE TABLE trigger_bypass_repl_parent (id int PRIMARY KEY);
+CREATE TABLE trigger_bypass_repl_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL,
+    CONSTRAINT trigger_bypass_repl_child_parent_id_fkey
+        FOREIGN KEY (parent_id) REFERENCES trigger_bypass_repl_parent (id)
+);
+INSERT INTO trigger_bypass_repl_parent VALUES (1);
+INSERT INTO trigger_bypass_repl_child VALUES (10, 1);
+SET session_replication_role = replica;
+SELECT count(*) AS key_join_rows
+FROM trigger_bypass_repl_parent p
+JOIN trigger_bypass_repl_child c FOR KEY (parent_id) -> p (id);
+RESET session_replication_role;
+DROP TABLE trigger_bypass_repl_child, trigger_bypass_repl_parent;
+
+-- Consumed query-shape uniqueness is revalidated on dependent view
+-- replacement.
+CREATE TABLE rv_unique_parent
+(
+    id int PRIMARY KEY
+);
+CREATE TABLE rv_unique_child
+(
+    parent_id int NOT NULL REFERENCES rv_unique_parent (id)
+);
+CREATE VIEW rv_unique_parent_v AS
+SELECT id FROM rv_unique_parent GROUP BY id;
+CREATE VIEW rv_unique_dep AS
+SELECT *
+FROM rv_unique_parent_v p
+-- accepted
+JOIN rv_unique_child c FOR KEY (parent_id) -> p (id);
+-- rejected, reason: replacement removes consumed query-shape uniqueness and row coverage
+CREATE OR REPLACE VIEW rv_unique_parent_v AS
+SELECT p.id FROM rv_unique_parent p CROSS JOIN (VALUES (1), (2)) AS g(n);
+DROP VIEW rv_unique_dep, rv_unique_parent_v;
+DROP TABLE rv_unique_child, rv_unique_parent;
+
+-- Bare unique indexes are uniqueness sources for surface facts, but producer
+-- views do not store those facts as DDL-blocking dependencies.
+CREATE TABLE bare_unique_parent
+(
+    id int NOT NULL
+);
+CREATE UNIQUE INDEX bare_unique_parent_id_idx ON bare_unique_parent (id);
+CREATE VIEW bare_unique_parent_v AS
+SELECT id FROM bare_unique_parent;
+DROP INDEX bare_unique_parent_id_idx;
+DROP VIEW bare_unique_parent_v;
+DROP TABLE bare_unique_parent;
+
+-- Adding an inheritance child to a parent that backs a stored key-join
+-- proof must revalidate dependent views and abort if the proof breaks.
+CREATE TABLE inh_kj_parent (id int PRIMARY KEY);
+CREATE TABLE inh_kj_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES inh_kj_parent (id)
+);
+INSERT INTO inh_kj_parent VALUES (1);
+INSERT INTO inh_kj_child VALUES (10, 1);
+
+CREATE VIEW inh_kj_v AS
+SELECT p.id AS parent_id, c.id AS child_id
+FROM inh_kj_parent p
+-- accepted
+JOIN inh_kj_child c FOR KEY (parent_id) -> p (id);
+
+-- rejected, reason: stored view depends on parent having no subclasses
+CREATE TABLE inh_kj_subclass () INHERITS (inh_kj_parent);
+
+CREATE TABLE inh_kj_outsider (id int NOT NULL);
+-- rejected, reason: same dependency, ALTER TABLE INHERIT path
+ALTER TABLE inh_kj_outsider INHERIT inh_kj_parent;
+
+-- After dropping the dependent view both DDL paths succeed.
+DROP VIEW inh_kj_v;
+CREATE TABLE inh_kj_subclass () INHERITS (inh_kj_parent);
+ALTER TABLE inh_kj_outsider INHERIT inh_kj_parent;
+DROP TABLE inh_kj_outsider, inh_kj_subclass, inh_kj_child, inh_kj_parent;
+
+-- Partitioned-table parent: ATTACH PARTITION must NOT trigger a false
+-- positive, because the parent's unique constraint is enforced across
+-- partitions.
+CREATE TABLE inh_kj_pparent (id int PRIMARY KEY) PARTITION BY RANGE (id);
+CREATE TABLE inh_kj_ppart0 PARTITION OF inh_kj_pparent
+    FOR VALUES FROM (0) TO (100);
+CREATE TABLE inh_kj_pchild
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES inh_kj_pparent (id)
+);
+INSERT INTO inh_kj_pparent VALUES (1);
+INSERT INTO inh_kj_pchild VALUES (10, 1);
+CREATE VIEW inh_kj_pv AS
+SELECT p.id AS parent_id, c.id AS child_id
+FROM inh_kj_pparent p
+-- accepted
+JOIN inh_kj_pchild c FOR KEY (parent_id) -> p (id);
+-- accepted: attaching another partition leaves the proof valid because
+-- partitioned-table parents enforce uniqueness across partitions.
+CREATE TABLE inh_kj_ppart1 PARTITION OF inh_kj_pparent
+    FOR VALUES FROM (100) TO (200);
+SELECT * FROM inh_kj_pv ORDER BY child_id;
+DROP VIEW inh_kj_pv;
+DROP TABLE inh_kj_pchild, inh_kj_pparent;
+
+-- ALTER TABLE ENABLE ROW LEVEL SECURITY must revalidate stored key-join
+-- proofs.  RLS-protected relations expose no key-join facts, so a stored
+-- proof against a relation that gains RLS becomes unprovable.
+CREATE TABLE rls_kj_parent (id int PRIMARY KEY);
+CREATE TABLE rls_kj_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES rls_kj_parent (id)
+);
+INSERT INTO rls_kj_parent VALUES (1);
+INSERT INTO rls_kj_child VALUES (10, 1);
+
+CREATE VIEW rls_kj_v AS
+SELECT p.id AS parent_id, c.id AS child_id
+FROM rls_kj_parent p
+-- accepted
+JOIN rls_kj_child c FOR KEY (parent_id) -> p (id);
+
+-- rejected, reason: stored view depends on RLS-free referenced surface
+ALTER TABLE rls_kj_parent ENABLE ROW LEVEL SECURITY;
+-- rejected, reason: same dependency, referencing side too
+ALTER TABLE rls_kj_child ENABLE ROW LEVEL SECURITY;
+
+-- After dropping the view, ENABLE ROW LEVEL SECURITY succeeds.
+DROP VIEW rls_kj_v;
+ALTER TABLE rls_kj_parent ENABLE ROW LEVEL SECURITY;
+-- Repeating the same setting does not need key-join revalidation.
+ALTER TABLE rls_kj_parent ENABLE ROW LEVEL SECURITY;
+ALTER TABLE rls_kj_parent DISABLE ROW LEVEL SECURITY;
+DROP TABLE rls_kj_child, rls_kj_parent;
+
+-- Materialized views must follow the same proof revalidation path as
+-- regular views.  Their _RETURN rule has the same shape, but the revalidation
+-- helper used to skip them because of a RELKIND_VIEW filter.
+CREATE TABLE mv_kj_parent (id int PRIMARY KEY);
+CREATE TABLE mv_kj_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES mv_kj_parent (id)
+);
+INSERT INTO mv_kj_parent VALUES (1);
+INSERT INTO mv_kj_child VALUES (10, 1);
+
+CREATE MATERIALIZED VIEW mv_kj AS
+SELECT p.id AS parent_id, c.id AS child_id
+FROM mv_kj_parent p
+-- accepted
+JOIN mv_kj_child c FOR KEY (parent_id) -> p (id);
+
+-- rejected, reason: stored matview proof depends on parent having no subclasses
+CREATE TABLE mv_kj_subclass () INHERITS (mv_kj_parent);
+-- rejected, reason: stored matview proof depends on RLS-free referenced surface
+ALTER TABLE mv_kj_parent ENABLE ROW LEVEL SECURITY;
+
+DROP MATERIALIZED VIEW mv_kj;
+DROP TABLE mv_kj_child, mv_kj_parent;
+
+-- Rules on plain tables can also contain FOR KEY joins in their action.
+-- The stored rule action carries proof annotations exactly like a view's
+-- _RETURN rule, so it must participate in the same revalidation.
+CREATE TABLE rule_kj_parent (id int PRIMARY KEY);
+CREATE TABLE rule_kj_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES rule_kj_parent (id)
+);
+CREATE TABLE rule_kj_log (id int);
+INSERT INTO rule_kj_parent VALUES (1);
+INSERT INTO rule_kj_child VALUES (10, 1);
+
+CREATE RULE rule_kj_action AS ON DELETE TO rule_kj_log
+DO ALSO SELECT count(*) FROM rule_kj_parent p
+-- accepted
+JOIN rule_kj_child c FOR KEY (parent_id) -> p (id);
+
+-- rejected, reason: stored rule action depends on parent having no subclasses
+CREATE TABLE rule_kj_subclass () INHERITS (rule_kj_parent);
+-- rejected, reason: stored rule action depends on RLS-free referenced surface
+ALTER TABLE rule_kj_parent ENABLE ROW LEVEL SECURITY;
+-- rejected, reason: stored rule action depends on the FK proof
+ALTER TABLE rule_kj_child
+    ALTER CONSTRAINT rule_kj_child_parent_id_fkey NOT ENFORCED;
+
+-- After dropping the rule, the same DDL succeeds.
+DROP RULE rule_kj_action ON rule_kj_log;
+CREATE TABLE rule_kj_subclass () INHERITS (rule_kj_parent);
+DROP TABLE rule_kj_subclass, rule_kj_log, rule_kj_child, rule_kj_parent;
+
+-- Rule qualifications can also contain stored key-join proofs in sublinks.
+CREATE TABLE rule_qual_kj_parent (id int PRIMARY KEY);
+CREATE TABLE rule_qual_kj_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES rule_qual_kj_parent (id)
+);
+CREATE TABLE rule_qual_kj_log (id int);
+INSERT INTO rule_qual_kj_parent VALUES (1);
+INSERT INTO rule_qual_kj_child VALUES (10, 1);
+
+CREATE RULE rule_qual_kj AS ON INSERT TO rule_qual_kj_log
+WHERE EXISTS (
+    SELECT 1
+    FROM rule_qual_kj_parent p
+    -- accepted
+    JOIN rule_qual_kj_child c FOR KEY (parent_id) -> p (id)
+)
+DO ALSO SELECT 1;
+
+-- rejected, reason: stored rule qualification depends on the FK proof
+ALTER TABLE rule_qual_kj_child
+    ALTER CONSTRAINT rule_qual_kj_child_parent_id_fkey NOT ENFORCED;
+
+-- After dropping the rule, the same DDL succeeds.
+DROP RULE rule_qual_kj ON rule_qual_kj_log;
+ALTER TABLE rule_qual_kj_child
+    ALTER CONSTRAINT rule_qual_kj_child_parent_id_fkey NOT ENFORCED;
+SELECT *
+FROM rule_qual_kj_parent p
+-- rejected, reason: FK is now not enforced
+JOIN rule_qual_kj_child c FOR KEY (parent_id) -> p (id);
+DROP TABLE rule_qual_kj_log, rule_qual_kj_child, rule_qual_kj_parent;
+
+-- Non-_RETURN rules on views also carry stored key-join proofs.
+CREATE TABLE rkv_parent (id int PRIMARY KEY);
+CREATE TABLE rkv_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES rkv_parent (id)
+);
+INSERT INTO rkv_parent VALUES (1);
+INSERT INTO rkv_child VALUES (10, 1);
+
+CREATE VIEW rkv_outer AS SELECT id FROM rkv_parent;
+
+CREATE RULE rkv_extra AS ON UPDATE TO rkv_outer
+DO INSTEAD SELECT count(*) FROM rkv_parent p
+-- accepted
+JOIN rkv_child c FOR KEY (parent_id) -> p (id);
+
+-- rejected, reason: stored rule action depends on parent having no subclasses
+CREATE TABLE rkv_subclass () INHERITS (rkv_parent);
+
+DROP RULE rkv_extra ON rkv_outer;
+DROP VIEW rkv_outer;
+DROP TABLE rkv_child, rkv_parent;
+
+-- ALTER FUNCTION and CREATE OR REPLACE FUNCTION on a function used in
+-- a stored matched-filter conjunct must revalidate dependent stored
+-- key-join proofs.  The proof's contain_volatile_functions check is
+-- decided at parse time; a later volatility, strictness, or body change
+-- can break the matched-filter contract.
+CREATE FUNCTION pf_kj_pick() RETURNS int
+    LANGUAGE plpgsql STABLE AS $$ BEGIN RETURN 1; END; $$;
+CREATE TABLE pf_kj_p (id int PRIMARY KEY);
+CREATE TABLE pf_kj_r (id int NOT NULL REFERENCES pf_kj_p (id));
+INSERT INTO pf_kj_p VALUES (1);
+INSERT INTO pf_kj_r VALUES (1);
+CREATE VIEW pf_kj_vp AS SELECT id FROM pf_kj_p WHERE id = pf_kj_pick();
+CREATE VIEW pf_kj_vr AS SELECT id FROM pf_kj_r WHERE id = pf_kj_pick();
+CREATE VIEW pf_kj_dep AS
+SELECT pf_kj_vp.id FROM pf_kj_vp
+-- accepted
+JOIN pf_kj_vr FOR KEY (id) -> pf_kj_vp (id);
+
+-- rejected, reason: stored proof depends on the function being non-volatile
+ALTER FUNCTION pf_kj_pick() VOLATILE;
+-- rejected, reason: CREATE OR REPLACE may change body or volatility
+CREATE OR REPLACE FUNCTION pf_kj_pick() RETURNS int
+    LANGUAGE plpgsql VOLATILE AS $$ BEGIN RETURN 2; END; $$;
+
+-- After dropping the consumer view, both DDL paths succeed.
+DROP VIEW pf_kj_dep;
+ALTER FUNCTION pf_kj_pick() VOLATILE;
+CREATE OR REPLACE FUNCTION pf_kj_pick() RETURNS int
+    LANGUAGE plpgsql VOLATILE AS $$ BEGIN RETURN 2; END; $$;
+DROP VIEW pf_kj_vp, pf_kj_vr;
+DROP TABLE pf_kj_r, pf_kj_p;
+DROP FUNCTION pf_kj_pick();
+
+-- A function's own SQL body must also be revalidated after replacement.
+-- The body is transformed while the old function properties are still
+-- visible, but it is stored with dependencies against the replaced function.
+CREATE FUNCTION pf_kj_self_replace_pick() RETURNS int
+    LANGUAGE sql STABLE RETURN 1;
+CREATE TABLE pf_kj_self_replace_p (id int PRIMARY KEY);
+CREATE TABLE pf_kj_self_replace_r (id int NOT NULL REFERENCES pf_kj_self_replace_p (id));
+INSERT INTO pf_kj_self_replace_p VALUES (1);
+INSERT INTO pf_kj_self_replace_r VALUES (1);
+CREATE VIEW pf_kj_self_replace_vp AS
+SELECT id FROM pf_kj_self_replace_p WHERE id = pf_kj_self_replace_pick();
+CREATE VIEW pf_kj_self_replace_vr AS
+SELECT id FROM pf_kj_self_replace_r WHERE id = pf_kj_self_replace_pick();
+-- rejected, reason: the function's own stored proof sees final VOLATILE state
+CREATE OR REPLACE FUNCTION pf_kj_self_replace_pick() RETURNS int
+    LANGUAGE sql VOLATILE
+    RETURN CASE WHEN false THEN (
+        SELECT count(*)::int
+        FROM pf_kj_self_replace_vp p
+        JOIN pf_kj_self_replace_vr r FOR KEY (id) -> p (id)
+    ) ELSE 1 END;
+DROP VIEW pf_kj_self_replace_vp, pf_kj_self_replace_vr;
+DROP TABLE pf_kj_self_replace_r, pf_kj_self_replace_p;
+DROP FUNCTION pf_kj_self_replace_pick();
+
+-- ALTER FUNCTION must revalidate the function's own stored SQL body too.
+CREATE FUNCTION pf_kj_self_alter_pick() RETURNS int
+    LANGUAGE sql STABLE RETURN 1;
+CREATE TABLE pf_kj_self_alter_p (id int PRIMARY KEY);
+CREATE TABLE pf_kj_self_alter_r (id int NOT NULL REFERENCES pf_kj_self_alter_p (id));
+INSERT INTO pf_kj_self_alter_p VALUES (1);
+INSERT INTO pf_kj_self_alter_r VALUES (1);
+CREATE VIEW pf_kj_self_alter_vp AS
+SELECT id FROM pf_kj_self_alter_p WHERE id = pf_kj_self_alter_pick();
+CREATE VIEW pf_kj_self_alter_vr AS
+SELECT id FROM pf_kj_self_alter_r WHERE id = pf_kj_self_alter_pick();
+-- accepted
+CREATE OR REPLACE FUNCTION pf_kj_self_alter_pick() RETURNS int
+    LANGUAGE sql STABLE
+    RETURN CASE WHEN false THEN (
+        SELECT count(*)::int
+        FROM pf_kj_self_alter_vp p
+        JOIN pf_kj_self_alter_vr r FOR KEY (id) -> p (id)
+    ) ELSE 1 END;
+-- rejected, reason: the function's own stored proof sees final VOLATILE state
+ALTER FUNCTION pf_kj_self_alter_pick() VOLATILE;
+CREATE OR REPLACE FUNCTION pf_kj_self_alter_pick() RETURNS int
+    LANGUAGE sql STABLE RETURN 1;
+DROP VIEW pf_kj_self_alter_vp, pf_kj_self_alter_vr;
+DROP TABLE pf_kj_self_alter_r, pf_kj_self_alter_p;
+DROP FUNCTION pf_kj_self_alter_pick();
+
+-- CoerceViaIO proof filters must depend on hidden type I/O functions too.
+-- Otherwise ALTER FUNCTION can make a stored proof stale without revalidation.
+SET client_min_messages = warning;
+CREATE TYPE pf_kj_io_type;
+CREATE FUNCTION pf_kj_io_type_in(cstring)
+RETURNS pf_kj_io_type
+LANGUAGE internal STRICT IMMUTABLE AS 'int4in';
+CREATE FUNCTION pf_kj_io_type_out(pf_kj_io_type)
+RETURNS cstring
+LANGUAGE internal STRICT IMMUTABLE AS 'int4out';
+CREATE TYPE pf_kj_io_type (
+    input = pf_kj_io_type_in,
+    output = pf_kj_io_type_out,
+    like = int4
+);
+RESET client_min_messages;
+CREATE CAST (pf_kj_io_type AS integer) WITH INOUT;
+CREATE TABLE pf_kj_io_p (id int PRIMARY KEY);
+CREATE TABLE pf_kj_io_r (id int NOT NULL REFERENCES pf_kj_io_p (id));
+INSERT INTO pf_kj_io_p VALUES (1), (2);
+INSERT INTO pf_kj_io_r VALUES (1);
+CREATE VIEW pf_kj_io_vp AS
+SELECT id FROM pf_kj_io_p WHERE id = ('1'::pf_kj_io_type)::integer;
+CREATE VIEW pf_kj_io_vr AS
+SELECT id FROM pf_kj_io_r WHERE id = ('1'::pf_kj_io_type)::integer;
+CREATE VIEW pf_kj_io_dep AS
+SELECT pf_kj_io_vp.id FROM pf_kj_io_vp
+-- accepted
+JOIN pf_kj_io_vr FOR KEY (id) -> pf_kj_io_vp (id);
+
+-- rejected, reason: stored proof depends on CoerceViaIO output being non-volatile
+ALTER FUNCTION pf_kj_io_type_out(pf_kj_io_type) VOLATILE;
+-- rejected, reason: CREATE OR REPLACE may change CoerceViaIO output volatility
+CREATE OR REPLACE FUNCTION pf_kj_io_type_out(pf_kj_io_type)
+RETURNS cstring
+LANGUAGE internal STRICT VOLATILE AS 'int4out';
+
+-- After dropping the consumer view, both DDL paths succeed.
+DROP VIEW pf_kj_io_dep;
+ALTER FUNCTION pf_kj_io_type_out(pf_kj_io_type) VOLATILE;
+CREATE OR REPLACE FUNCTION pf_kj_io_type_out(pf_kj_io_type)
+RETURNS cstring
+LANGUAGE internal STRICT VOLATILE AS 'int4out';
+DROP VIEW pf_kj_io_vp, pf_kj_io_vr;
+DROP TABLE pf_kj_io_r, pf_kj_io_p;
+DROP CAST (pf_kj_io_type AS integer);
+SET client_min_messages = warning;
+DROP TYPE pf_kj_io_type CASCADE;
+RESET client_min_messages;
+
+-- New-style SQL function bodies store analyzed key-join proofs, so DDL that
+-- changes a consumed proof dependency must revalidate those stored bodies.
+CREATE TABLE sqlbody_kj_view_parent (id int PRIMARY KEY);
+CREATE TABLE sqlbody_kj_view_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES sqlbody_kj_view_parent (id)
+);
+INSERT INTO sqlbody_kj_view_parent VALUES (1), (2);
+INSERT INTO sqlbody_kj_view_child VALUES (10, 1), (20, 2);
+CREATE VIEW sqlbody_kj_view_parent_v AS
+SELECT id FROM sqlbody_kj_view_parent;
+CREATE FUNCTION sqlbody_kj_view_dep() RETURNS TABLE(child_id int)
+LANGUAGE sql
+BEGIN ATOMIC
+    SELECT c.id AS child_id
+    FROM sqlbody_kj_view_parent_v p
+    -- accepted
+    JOIN sqlbody_kj_view_child c FOR KEY (parent_id) -> p (id)
+    ORDER BY c.id;
+END;
+
+-- rejected, reason: stored SQL function body depends on referenced rows
+-- being preserved by the view.
+CREATE OR REPLACE VIEW sqlbody_kj_view_parent_v AS
+SELECT id FROM sqlbody_kj_view_parent WHERE id = 1;
+SELECT * FROM sqlbody_kj_view_dep();
+DROP FUNCTION sqlbody_kj_view_dep();
+DROP VIEW sqlbody_kj_view_parent_v;
+DROP TABLE sqlbody_kj_view_child, sqlbody_kj_view_parent;
+
+-- Stored SQL function bodies must also be revalidated when FK proof flags
+-- change.
+CREATE TABLE sqlbody_kj_fk_parent (id int PRIMARY KEY);
+CREATE TABLE sqlbody_kj_fk_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL,
+    CONSTRAINT sqlbody_kj_fk_child_parent_id_fkey
+        FOREIGN KEY (parent_id) REFERENCES sqlbody_kj_fk_parent (id)
+);
+INSERT INTO sqlbody_kj_fk_parent VALUES (1);
+INSERT INTO sqlbody_kj_fk_child VALUES (10, 1);
+CREATE FUNCTION sqlbody_kj_fk_dep() RETURNS TABLE(child_id int)
+LANGUAGE sql
+BEGIN ATOMIC
+    SELECT c.id AS child_id
+    FROM sqlbody_kj_fk_parent p
+    -- accepted
+    JOIN sqlbody_kj_fk_child c FOR KEY (parent_id) -> p (id);
+END;
+
+-- rejected, reason: stored SQL function body depends on the FK proof
+ALTER TABLE sqlbody_kj_fk_child
+    ALTER CONSTRAINT sqlbody_kj_fk_child_parent_id_fkey NOT ENFORCED;
+-- rejected, reason: stored SQL function body depends on the non-deferrable FK proof
+ALTER TABLE sqlbody_kj_fk_child
+    ALTER CONSTRAINT sqlbody_kj_fk_child_parent_id_fkey
+    DEFERRABLE INITIALLY IMMEDIATE;
+SELECT * FROM sqlbody_kj_fk_dep();
+DROP FUNCTION sqlbody_kj_fk_dep();
+DROP TABLE sqlbody_kj_fk_child, sqlbody_kj_fk_parent;
+
+-- Revalidating a SQL function body must reject producer replacement when
+-- replay would require a new proof dependency.  Explicit recreation stores
+-- the new proof source and stops blocking the old one.
+CREATE TABLE sqlbody_kj_refresh_parent_a (id int PRIMARY KEY);
+CREATE TABLE sqlbody_kj_refresh_parent_b (id int PRIMARY KEY);
+CREATE TABLE sqlbody_kj_refresh_child
+(
+    id int PRIMARY KEY,
+    ref int NOT NULL,
+    CONSTRAINT sqlbody_kj_refresh_child_ref_a_fkey
+        FOREIGN KEY (ref) REFERENCES sqlbody_kj_refresh_parent_a (id),
+    CONSTRAINT sqlbody_kj_refresh_child_ref_b_fkey
+        FOREIGN KEY (ref) REFERENCES sqlbody_kj_refresh_parent_b (id)
+);
+INSERT INTO sqlbody_kj_refresh_parent_a VALUES (1);
+INSERT INTO sqlbody_kj_refresh_parent_b VALUES (1);
+INSERT INTO sqlbody_kj_refresh_child VALUES (10, 1);
+CREATE VIEW sqlbody_kj_refresh_parent_v AS
+SELECT id FROM sqlbody_kj_refresh_parent_a;
+CREATE FUNCTION sqlbody_kj_refresh_dep() RETURNS TABLE(child_id int)
+LANGUAGE sql
+BEGIN ATOMIC
+    SELECT c.id AS child_id
+    FROM sqlbody_kj_refresh_parent_v p
+    -- accepted
+    JOIN sqlbody_kj_refresh_child c FOR KEY (ref) -> p (id);
+END;
+-- rejected, reason: replay would require the parent_b FK proof
+CREATE OR REPLACE VIEW sqlbody_kj_refresh_parent_v AS
+SELECT id FROM sqlbody_kj_refresh_parent_b;
+
+DROP FUNCTION sqlbody_kj_refresh_dep();
+CREATE OR REPLACE VIEW sqlbody_kj_refresh_parent_v AS
+SELECT id FROM sqlbody_kj_refresh_parent_b;
+CREATE FUNCTION sqlbody_kj_refresh_dep() RETURNS TABLE(child_id int)
+LANGUAGE sql
+BEGIN ATOMIC
+    SELECT c.id AS child_id
+    FROM sqlbody_kj_refresh_parent_v p
+    -- accepted
+    JOIN sqlbody_kj_refresh_child c FOR KEY (ref) -> p (id);
+END;
+SELECT * FROM sqlbody_kj_refresh_dep();
+ALTER TABLE sqlbody_kj_refresh_child
+    DROP CONSTRAINT sqlbody_kj_refresh_child_ref_a_fkey;
+-- rejected, reason: recreated SQL function body depends on the new FK proof
+ALTER TABLE sqlbody_kj_refresh_child
+    DROP CONSTRAINT sqlbody_kj_refresh_child_ref_b_fkey;
+SELECT * FROM sqlbody_kj_refresh_dep();
+DROP FUNCTION sqlbody_kj_refresh_dep();
+DROP VIEW sqlbody_kj_refresh_parent_v;
+DROP TABLE sqlbody_kj_refresh_child,
+           sqlbody_kj_refresh_parent_b,
+           sqlbody_kj_refresh_parent_a;
+
+-- Producer-side DDL may affect key-join proofs stored in dependent
+-- objects owned by other roles.  If those proofs still validate and
+-- require no new dependencies, the producer owner can replace the
+-- producer view without owning the dependent view.
+CREATE ROLE regress_kj_cross_owner_producer;
+CREATE ROLE regress_kj_cross_owner_consumer;
+CREATE SCHEMA kj_cross_owner_p AUTHORIZATION regress_kj_cross_owner_producer;
+CREATE SCHEMA kj_cross_owner_c AUTHORIZATION regress_kj_cross_owner_consumer;
+
+SET ROLE regress_kj_cross_owner_producer;
+CREATE TABLE kj_cross_owner_p.parent (id int PRIMARY KEY);
+CREATE TABLE kj_cross_owner_p.child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES kj_cross_owner_p.parent (id)
+);
+CREATE VIEW kj_cross_owner_p.parent_v AS
+SELECT id FROM kj_cross_owner_p.parent;
+GRANT USAGE ON SCHEMA kj_cross_owner_p TO regress_kj_cross_owner_consumer;
+GRANT SELECT ON kj_cross_owner_p.parent_v TO regress_kj_cross_owner_consumer;
+GRANT SELECT ON kj_cross_owner_p.child TO regress_kj_cross_owner_consumer;
+RESET ROLE;
+
+SET ROLE regress_kj_cross_owner_consumer;
+CREATE VIEW kj_cross_owner_c.dep AS
+SELECT p.id AS parent_id, c.id AS child_id
+FROM kj_cross_owner_p.parent_v p
+-- accepted
+JOIN kj_cross_owner_p.child c FOR KEY (parent_id) -> p (id);
+RESET ROLE;
+
+SET ROLE regress_kj_cross_owner_producer;
+CREATE OR REPLACE VIEW kj_cross_owner_p.parent_v AS
+SELECT id FROM kj_cross_owner_p.parent;
+RESET ROLE;
+
+DROP SCHEMA kj_cross_owner_c CASCADE;
+DROP SCHEMA kj_cross_owner_p CASCADE;
+DROP ROLE regress_kj_cross_owner_consumer;
+DROP ROLE regress_kj_cross_owner_producer;
+
+-- A key join can preserve referenced-side uniqueness only when the
+-- referencing side is unique.  Stored proofs that consume the projected
+-- uniqueness must depend on that referencing-side unique proof.
+CREATE TABLE output_unique_kj_parent (id int PRIMARY KEY);
+CREATE TABLE output_unique_kj_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL,
+    CONSTRAINT output_unique_kj_child_parent_id_key UNIQUE (parent_id),
+    CONSTRAINT output_unique_kj_child_parent_id_fkey
+        FOREIGN KEY (parent_id) REFERENCES output_unique_kj_parent (id)
+);
+CREATE TABLE output_unique_kj_grandchild
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES output_unique_kj_parent (id)
+);
+INSERT INTO output_unique_kj_parent VALUES (1), (2);
+INSERT INTO output_unique_kj_child VALUES (10, 1);
+INSERT INTO output_unique_kj_grandchild VALUES (100, 1), (200, 2);
+CREATE VIEW output_unique_kj_view AS
+SELECT q.parent_id, q.child_id, gc.id AS grandchild_id
+FROM (
+    SELECT p.id AS parent_id, c.id AS child_id
+    FROM output_unique_kj_parent p
+    LEFT JOIN output_unique_kj_child c FOR KEY (parent_id) -> p (id)
+) q
+-- accepted because output_unique_kj_child.parent_id is unique
+JOIN output_unique_kj_grandchild gc FOR KEY (parent_id) -> q (parent_id);
+
+-- rejected, reason: the stored view depends on the referencing-side uniqueness
+-- that preserved q.parent_id uniqueness.
+ALTER TABLE output_unique_kj_child
+    DROP CONSTRAINT output_unique_kj_child_parent_id_key;
+
+-- After dropping the consumer view, the same DDL succeeds and the raw nested
+-- key join is no longer provable.
+DROP VIEW output_unique_kj_view;
+ALTER TABLE output_unique_kj_child
+    DROP CONSTRAINT output_unique_kj_child_parent_id_key;
+SELECT q.parent_id, q.child_id, gc.id AS grandchild_id
+FROM (
+    SELECT p.id AS parent_id, c.id AS child_id
+    FROM output_unique_kj_parent p
+    LEFT JOIN output_unique_kj_child c FOR KEY (parent_id) -> p (id)
+) q
+JOIN output_unique_kj_grandchild gc
+    FOR KEY (parent_id) -> q (parent_id);
+DROP TABLE output_unique_kj_grandchild,
+           output_unique_kj_child,
+           output_unique_kj_parent;
+
+-- Referenced-side FK facts survive outer null-extension as nullable FK facts.
+CREATE TABLE output_fk_null_ext_gp (id int PRIMARY KEY);
+CREATE TABLE output_fk_null_ext_parent
+(
+    id int PRIMARY KEY,
+    gp_id int NOT NULL REFERENCES output_fk_null_ext_gp (id)
+);
+CREATE TABLE output_fk_null_ext_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES output_fk_null_ext_parent (id)
+);
+INSERT INTO output_fk_null_ext_gp VALUES (1), (2);
+INSERT INTO output_fk_null_ext_parent VALUES (10, 1), (20, 2);
+INSERT INTO output_fk_null_ext_child VALUES (100, 10);
+
+-- Control: a non-null-extending referenced side already exported the FK fact.
+SELECT q.parent_id, q.gp_id, q.child_id, g.id AS gp_match
+FROM (
+    SELECT p.id AS parent_id, p.gp_id, c.id AS child_id
+    FROM output_fk_null_ext_parent p
+    LEFT JOIN output_fk_null_ext_child c FOR KEY (parent_id) -> p (id)
+) q
+LEFT JOIN output_fk_null_ext_gp g FOR KEY (id) <- q (gp_id)
+ORDER BY q.parent_id, q.child_id, g.id;
+
+-- A FULL JOIN can null-extend the referenced side, but non-null FK values
+-- projected from that side still have FK-backed containment.
+SELECT q.parent_id, q.gp_id, q.child_id, g.id AS gp_match
+FROM (
+    SELECT p.id AS parent_id, p.gp_id, c.id AS child_id
+    FROM output_fk_null_ext_parent p
+    FULL JOIN output_fk_null_ext_child c FOR KEY (parent_id) -> p (id)
+) q
+LEFT JOIN output_fk_null_ext_gp g FOR KEY (id) <- q (gp_id)
+ORDER BY q.parent_id, q.child_id, g.id;
+
+-- The FK fact survived, but not the not-null fact, so an inner key join
+-- through the same null-extended surface is still rejected.
+SELECT q.parent_id, q.gp_id, q.child_id, g.id AS gp_match
+FROM (
+    SELECT p.id AS parent_id, p.gp_id, c.id AS child_id
+    FROM output_fk_null_ext_parent p
+    FULL JOIN output_fk_null_ext_child c FOR KEY (parent_id) -> p (id)
+) q
+JOIN output_fk_null_ext_gp g FOR KEY (id) <- q (gp_id);
+
+DROP TABLE output_fk_null_ext_child,
+           output_fk_null_ext_parent,
+           output_fk_null_ext_gp;
+
+-- Stronger case: the FULL JOIN can actually produce a row whose referenced
+-- side is null-extended.  The later preserved-side key join remains valid
+-- because nullable FK values are allowed when the referencing side is preserved.
+CREATE TABLE output_fk_nullable_gp (id int PRIMARY KEY);
+CREATE TABLE output_fk_nullable_parent
+(
+    id int PRIMARY KEY,
+    gp_id int NOT NULL REFERENCES output_fk_nullable_gp (id)
+);
+CREATE TABLE output_fk_nullable_child
+(
+    id int PRIMARY KEY,
+    parent_id int REFERENCES output_fk_nullable_parent (id)
+);
+INSERT INTO output_fk_nullable_gp VALUES (1), (2);
+INSERT INTO output_fk_nullable_parent VALUES (10, 1), (20, 2);
+INSERT INTO output_fk_nullable_child VALUES (100, 10), (200, NULL);
+
+SELECT q.parent_id, q.gp_id, q.child_id, g.id AS gp_match
+FROM (
+    SELECT p.id AS parent_id, p.gp_id, c.id AS child_id
+    FROM output_fk_nullable_parent p
+    FULL JOIN output_fk_nullable_child c FOR KEY (parent_id) -> p (id)
+) q
+LEFT JOIN output_fk_nullable_gp g FOR KEY (id) <- q (gp_id)
+ORDER BY q.parent_id NULLS LAST, q.child_id, g.id;
+
+DROP TABLE output_fk_nullable_child,
+           output_fk_nullable_parent,
+           output_fk_nullable_gp;
+
+-- New-style SQL function bodies follow the same stored dependency path.
+CREATE TABLE output_unique_sqlbody_parent (id int PRIMARY KEY);
+CREATE TABLE output_unique_sqlbody_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL,
+    CONSTRAINT output_unique_sqlbody_child_parent_id_key UNIQUE (parent_id),
+    CONSTRAINT output_unique_sqlbody_child_parent_id_fkey
+        FOREIGN KEY (parent_id) REFERENCES output_unique_sqlbody_parent (id)
+);
+CREATE TABLE output_unique_sqlbody_grandchild
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES output_unique_sqlbody_parent (id)
+);
+INSERT INTO output_unique_sqlbody_parent VALUES (1), (2);
+INSERT INTO output_unique_sqlbody_child VALUES (10, 1);
+INSERT INTO output_unique_sqlbody_grandchild VALUES (100, 1), (200, 2);
+CREATE FUNCTION output_unique_sqlbody_dep()
+RETURNS TABLE(parent_id int, child_id int, grandchild_id int)
+LANGUAGE sql
+BEGIN ATOMIC
+    SELECT q.parent_id, q.child_id, gc.id AS grandchild_id
+    FROM (
+        SELECT p.id AS parent_id, c.id AS child_id
+        FROM output_unique_sqlbody_parent p
+        LEFT JOIN output_unique_sqlbody_child c FOR KEY (parent_id) -> p (id)
+    ) q
+    -- accepted because output_unique_sqlbody_child.parent_id is unique
+    JOIN output_unique_sqlbody_grandchild gc FOR KEY (parent_id) -> q (parent_id);
+END;
+
+-- rejected, reason: the stored function body depends on the referencing-side
+-- uniqueness that preserved q.parent_id uniqueness.
+ALTER TABLE output_unique_sqlbody_child
+    DROP CONSTRAINT output_unique_sqlbody_child_parent_id_key;
+
+-- After dropping the consumer function, the same DDL succeeds and the raw
+-- nested key join is no longer provable.
+DROP FUNCTION output_unique_sqlbody_dep();
+ALTER TABLE output_unique_sqlbody_child
+    DROP CONSTRAINT output_unique_sqlbody_child_parent_id_key;
+SELECT q.parent_id, q.child_id, gc.id AS grandchild_id
+FROM (
+    SELECT p.id AS parent_id, c.id AS child_id
+    FROM output_unique_sqlbody_parent p
+    LEFT JOIN output_unique_sqlbody_child c FOR KEY (parent_id) -> p (id)
+) q
+JOIN output_unique_sqlbody_grandchild gc
+    FOR KEY (parent_id) -> q (parent_id);
+DROP TABLE output_unique_sqlbody_grandchild,
+           output_unique_sqlbody_child,
+           output_unique_sqlbody_parent;
+
+-- RLS policy USING expressions store analyzed key-join proofs and must be
+-- revalidated when their proof dependencies change.
+CREATE TABLE policy_using_kj_parent (id int PRIMARY KEY);
+CREATE TABLE policy_using_kj_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES policy_using_kj_parent (id)
+);
+CREATE TABLE policy_using_kj_guarded (id int);
+INSERT INTO policy_using_kj_parent VALUES (1);
+INSERT INTO policy_using_kj_child VALUES (10, 1);
+ALTER TABLE policy_using_kj_guarded ENABLE ROW LEVEL SECURITY;
+CREATE POLICY policy_using_kj ON policy_using_kj_guarded
+USING (EXISTS (
+    SELECT 1
+    FROM policy_using_kj_parent p
+    -- accepted
+    JOIN policy_using_kj_child c FOR KEY (parent_id) -> p (id)
+));
+
+-- rejected, reason: stored policy USING expression depends on the FK proof
+ALTER TABLE policy_using_kj_child
+    ALTER CONSTRAINT policy_using_kj_child_parent_id_fkey NOT ENFORCED;
+
+-- After dropping the policy, the same DDL succeeds.
+DROP POLICY policy_using_kj ON policy_using_kj_guarded;
+ALTER TABLE policy_using_kj_child
+    ALTER CONSTRAINT policy_using_kj_child_parent_id_fkey NOT ENFORCED;
+SELECT *
+FROM policy_using_kj_parent p
+-- rejected, reason: FK is now not enforced
+JOIN policy_using_kj_child c FOR KEY (parent_id) -> p (id);
+DROP TABLE policy_using_kj_guarded,
+           policy_using_kj_child,
+           policy_using_kj_parent;
+
+-- RLS policy WITH CHECK expressions follow the same revalidation path.
+CREATE TABLE policy_check_kj_parent (id int PRIMARY KEY);
+CREATE TABLE policy_check_kj_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES policy_check_kj_parent (id)
+);
+CREATE TABLE policy_check_kj_guarded (id int);
+INSERT INTO policy_check_kj_parent VALUES (1);
+INSERT INTO policy_check_kj_child VALUES (10, 1);
+ALTER TABLE policy_check_kj_guarded ENABLE ROW LEVEL SECURITY;
+CREATE POLICY policy_check_kj ON policy_check_kj_guarded
+FOR INSERT
+WITH CHECK (EXISTS (
+    SELECT 1
+    FROM policy_check_kj_parent p
+    -- accepted
+    JOIN policy_check_kj_child c FOR KEY (parent_id) -> p (id)
+));
+
+-- rejected, reason: stored policy WITH CHECK expression depends on the FK proof
+ALTER TABLE policy_check_kj_child
+    ALTER CONSTRAINT policy_check_kj_child_parent_id_fkey NOT ENFORCED;
+
+-- After dropping the policy, the same DDL succeeds.
+DROP POLICY policy_check_kj ON policy_check_kj_guarded;
+ALTER TABLE policy_check_kj_child
+    ALTER CONSTRAINT policy_check_kj_child_parent_id_fkey NOT ENFORCED;
+SELECT *
+FROM policy_check_kj_parent p
+-- rejected, reason: FK is now not enforced
+JOIN policy_check_kj_child c FOR KEY (parent_id) -> p (id);
+DROP TABLE policy_check_kj_guarded,
+           policy_check_kj_child,
+           policy_check_kj_parent;
+
+-- Old-style quoted SQL functions do not have stored prosqlbody proofs; they
+-- still revalidate when their text is parsed for execution.
+CREATE TABLE sqlbody_kj_old_parent (id int PRIMARY KEY);
+CREATE TABLE sqlbody_kj_old_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES sqlbody_kj_old_parent (id)
+);
+INSERT INTO sqlbody_kj_old_parent VALUES (1), (2);
+INSERT INTO sqlbody_kj_old_child VALUES (10, 1), (20, 2);
+CREATE VIEW sqlbody_kj_old_parent_v AS
+SELECT id FROM sqlbody_kj_old_parent;
+CREATE FUNCTION sqlbody_kj_old_dep() RETURNS TABLE(child_id int)
+LANGUAGE sql SECURITY DEFINER AS $$
+    SELECT c.id AS child_id
+    FROM sqlbody_kj_old_parent_v p
+    JOIN sqlbody_kj_old_child c FOR KEY (parent_id) -> p (id)
+    ORDER BY c.id
+$$;
+SELECT * FROM sqlbody_kj_old_dep();
+CREATE OR REPLACE VIEW sqlbody_kj_old_parent_v AS
+SELECT id FROM sqlbody_kj_old_parent WHERE id = 1;
+-- rejected, reason: old-style function text is revalidated at execution
+SELECT * FROM sqlbody_kj_old_dep();
+DROP FUNCTION sqlbody_kj_old_dep();
+DROP VIEW sqlbody_kj_old_parent_v;
+DROP TABLE sqlbody_kj_old_child, sqlbody_kj_old_parent;
+
+-- Query-level uniqueness on the referenced side must not be paired with row
+-- coverage from an unrelated relation that happens to expose the same column
+-- number.
+CREATE TABLE key_join_wrong_coverage_parent
+(
+    id int PRIMARY KEY
+);
+CREATE TABLE key_join_wrong_coverage_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES key_join_wrong_coverage_parent (id)
+);
+CREATE TABLE key_join_wrong_coverage_other
+(
+    id int PRIMARY KEY
+);
+INSERT INTO key_join_wrong_coverage_parent VALUES (1), (2);
+INSERT INTO key_join_wrong_coverage_child VALUES (10, 2);
+INSERT INTO key_join_wrong_coverage_other VALUES (1);
+
+SELECT c.id, c.parent_id, o.id AS other_id
+FROM key_join_wrong_coverage_child c
+-- rejected, reason: referenced operand does not cover parent_id = 2
+JOIN (SELECT id FROM key_join_wrong_coverage_other GROUP BY id) o
+    FOR KEY (id) <- c (parent_id);
+
+DROP TABLE key_join_wrong_coverage_child,
+           key_join_wrong_coverage_other,
+           key_join_wrong_coverage_parent;
+
+-- Referenced-side uniqueness and row coverage must use the same key identity
+-- as the FK proof.  When an older alternate unique index appears before the
+-- primary-key index, proof selection must skip it and continue to the primary
+-- key instead of asserting.
+CREATE FUNCTION key_join_refidx_eq(a int, b int) RETURNS boolean
+LANGUAGE sql IMMUTABLE STRICT AS $$ SELECT a = b $$;
+CREATE OPERATOR =# (
+    LEFTARG = int,
+    RIGHTARG = int,
+    FUNCTION = key_join_refidx_eq,
+    COMMUTATOR = =#,
+    RESTRICT = eqsel,
+    JOIN = eqjoinsel,
+    MERGES
+);
+CREATE OPERATOR CLASS key_join_refidx_int4_ops
+FOR TYPE int USING btree AS
+    OPERATOR 3 =# (int, int),
+    FUNCTION 1 btint4cmp(int, int);
+
+CREATE TABLE key_join_refidx_parent
+(
+    id int NOT NULL
+);
+CREATE UNIQUE INDEX key_join_refidx_parent_alt_idx
+    ON key_join_refidx_parent USING btree (id key_join_refidx_int4_ops);
+ALTER TABLE key_join_refidx_parent ADD PRIMARY KEY (id);
+CREATE TABLE key_join_refidx_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES key_join_refidx_parent
+);
+INSERT INTO key_join_refidx_parent VALUES (1), (2);
+INSERT INTO key_join_refidx_child VALUES (10, 1), (20, 2);
+
+SELECT c.id AS child_id, p.id AS parent_id
+FROM key_join_refidx_parent p
+JOIN key_join_refidx_child c FOR KEY (parent_id) -> p (id)
+ORDER BY c.id;
+
+DROP TABLE key_join_refidx_child,
+           key_join_refidx_parent;
+DROP OPERATOR CLASS key_join_refidx_int4_ops USING btree;
+DROP OPERATOR FAMILY key_join_refidx_int4_ops USING btree;
+DROP OPERATOR =# (int, int);
+DROP FUNCTION key_join_refidx_eq(int, int);
+
+-- A duplicate-column unique index that appears before the real FK target
+-- index must be skipped, not asserted over while matching selected columns.
+CREATE TABLE key_join_dupidx_parent
+(
+    id int NOT NULL,
+    other_id int NOT NULL
+);
+CREATE UNIQUE INDEX key_join_dupidx_parent_dup_idx
+    ON key_join_dupidx_parent (id, id);
+CREATE UNIQUE INDEX key_join_dupidx_parent_real_idx
+    ON key_join_dupidx_parent (id, other_id);
+CREATE TABLE key_join_dupidx_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL,
+    parent_other_id int NOT NULL,
+    FOREIGN KEY (parent_id, parent_other_id)
+        REFERENCES key_join_dupidx_parent (id, other_id)
+);
+INSERT INTO key_join_dupidx_parent VALUES (1, 10), (2, 20);
+INSERT INTO key_join_dupidx_child VALUES (101, 1, 10), (202, 2, 20);
+
+SELECT c.id AS child_id, p.id AS parent_id, p.other_id
+FROM key_join_dupidx_parent p
+JOIN key_join_dupidx_child c
+    FOR KEY (parent_id, parent_other_id) -> p (id, other_id)
+ORDER BY c.id;
+
+DROP TABLE key_join_dupidx_child,
+           key_join_dupidx_parent;
+
+-- Referencing-side uniqueness cannot be projected through a key join unless it
+-- uses the same key-position identity as the FK proof.
+CREATE FUNCTION key_join_refuniq_bucket_cmp(a int, b int) RETURNS int
+LANGUAGE sql IMMUTABLE STRICT AS
+$$ SELECT CASE WHEN a / 10 < b / 10 THEN -1
+               WHEN a / 10 > b / 10 THEN 1
+               ELSE 0 END $$;
+CREATE FUNCTION key_join_refuniq_bucket_eq(a int, b int) RETURNS boolean
+LANGUAGE sql IMMUTABLE STRICT AS $$ SELECT a / 10 = b / 10 $$;
+CREATE OPERATOR =# (
+    LEFTARG = int,
+    RIGHTARG = int,
+    FUNCTION = key_join_refuniq_bucket_eq,
+    COMMUTATOR = =#,
+    RESTRICT = eqsel,
+    JOIN = eqjoinsel,
+    MERGES
+);
+CREATE OPERATOR CLASS key_join_refuniq_bucket_int4_ops
+FOR TYPE int USING btree AS
+    OPERATOR 3 =# (int, int),
+    FUNCTION 1 key_join_refuniq_bucket_cmp(int, int);
+
+CREATE TABLE key_join_refuniq_parent
+(
+    id int NOT NULL
+);
+CREATE UNIQUE INDEX key_join_refuniq_parent_idx
+    ON key_join_refuniq_parent USING btree (id key_join_refuniq_bucket_int4_ops);
+CREATE TABLE key_join_refuniq_child
+(
+    id int PRIMARY KEY,
+    parent_id int UNIQUE NOT NULL REFERENCES key_join_refuniq_parent (id)
+);
+CREATE TABLE key_join_refuniq_grandchild
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES key_join_refuniq_parent (id)
+);
+INSERT INTO key_join_refuniq_parent VALUES (10), (20);
+INSERT INTO key_join_refuniq_child VALUES (1, 10), (2, 11), (3, 20);
+INSERT INTO key_join_refuniq_grandchild VALUES (101, 10), (102, 20);
+
+SELECT p.id AS parent_key, c.id AS child_id, c.parent_id
+FROM key_join_refuniq_parent p
+LEFT JOIN key_join_refuniq_child c FOR KEY (parent_id) -> p (id)
+ORDER BY 1, 2;
+
+SELECT q.parent_key, q.child_id, g.id AS grandchild_id
+FROM (
+    SELECT p.id AS parent_key, c.id AS child_id, c.parent_id
+    FROM key_join_refuniq_parent p
+    LEFT JOIN key_join_refuniq_child c FOR KEY (parent_id) -> p (id)
+) q
+JOIN key_join_refuniq_grandchild g FOR KEY (parent_id) -> q (parent_key)
+ORDER BY q.parent_key, q.child_id, g.id;
+
+DROP TABLE key_join_refuniq_grandchild,
+           key_join_refuniq_child,
+           key_join_refuniq_parent;
+DROP OPERATOR CLASS key_join_refuniq_bucket_int4_ops USING btree;
+DROP OPERATOR FAMILY key_join_refuniq_bucket_int4_ops USING btree;
+DROP OPERATOR =# (int, int);
+DROP FUNCTION key_join_refuniq_bucket_cmp(int, int);
+DROP FUNCTION key_join_refuniq_bucket_eq(int, int);
+
+-- Set-returning equality operator functions are not usable proof operators.
+CREATE FUNCTION key_join_srf_eq(a int, b int) RETURNS SETOF boolean
+LANGUAGE sql IMMUTABLE STRICT AS $$ SELECT a = b $$;
+CREATE FUNCTION key_join_srf_cmp(a int, b int) RETURNS int
+LANGUAGE sql IMMUTABLE STRICT AS $$ SELECT btint4cmp(a, b) $$;
+CREATE OPERATOR =# (
+    LEFTARG = int,
+    RIGHTARG = int,
+    FUNCTION = key_join_srf_eq,
+    COMMUTATOR = =#,
+    RESTRICT = eqsel,
+    JOIN = eqjoinsel,
+    MERGES
+);
+CREATE OPERATOR CLASS key_join_srf_int4_ops
+FOR TYPE int USING btree AS
+    OPERATOR 1 < (int, int),
+    OPERATOR 2 <= (int, int),
+    OPERATOR 3 =# (int, int),
+    OPERATOR 4 >= (int, int),
+    OPERATOR 5 > (int, int),
+    FUNCTION 1 key_join_srf_cmp(int, int);
+
+CREATE TABLE key_join_srf_parent
+(
+    id int NOT NULL
+);
+CREATE UNIQUE INDEX key_join_srf_parent_idx
+    ON key_join_srf_parent USING btree (id key_join_srf_int4_ops);
+CREATE TABLE key_join_srf_child
+(
+    parent_id int NOT NULL REFERENCES key_join_srf_parent (id)
+);
+
+SELECT *
+FROM key_join_srf_parent p
+JOIN key_join_srf_child c FOR KEY (parent_id) -> p (id);
+
+DROP TABLE key_join_srf_child,
+           key_join_srf_parent;
+DROP OPERATOR CLASS key_join_srf_int4_ops USING btree;
+DROP OPERATOR FAMILY key_join_srf_int4_ops USING btree;
+DROP OPERATOR =# (int, int);
+DROP FUNCTION key_join_srf_eq(int, int);
+DROP FUNCTION key_join_srf_cmp(int, int);
+
+-- Generated key-join quals must require SELECT on both key columns.
+CREATE ROLE regress_key_join_priv_user;
+CREATE SCHEMA key_join_priv;
+CREATE TABLE key_join_priv.parent
+(
+    id int PRIMARY KEY,
+    visible text
+);
+CREATE TABLE key_join_priv.child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES key_join_priv.parent (id),
+    visible text
+);
+INSERT INTO key_join_priv.parent VALUES (1, 'parent-one');
+INSERT INTO key_join_priv.child VALUES (10, 1, 'child-one');
+GRANT USAGE ON SCHEMA key_join_priv TO regress_key_join_priv_user;
+GRANT SELECT (visible) ON key_join_priv.parent TO regress_key_join_priv_user;
+GRANT SELECT (visible) ON key_join_priv.child TO regress_key_join_priv_user;
+
+SET ROLE regress_key_join_priv_user;
+SELECT p.visible AS parent_visible, c.visible AS child_visible
+FROM key_join_priv.parent p
+JOIN key_join_priv.child c FOR KEY (parent_id) -> p (id);
+RESET ROLE;
+
+GRANT SELECT (id) ON key_join_priv.parent TO regress_key_join_priv_user;
+SET ROLE regress_key_join_priv_user;
+SELECT p.visible AS parent_visible, c.visible AS child_visible
+FROM key_join_priv.parent p
+JOIN key_join_priv.child c FOR KEY (parent_id) -> p (id);
+RESET ROLE;
+
+GRANT SELECT (parent_id) ON key_join_priv.child TO regress_key_join_priv_user;
+SET ROLE regress_key_join_priv_user;
+SELECT p.visible AS parent_visible, c.visible AS child_visible
+FROM key_join_priv.parent p
+JOIN key_join_priv.child c FOR KEY (parent_id) -> p (id);
+RESET ROLE;
+
+DROP SCHEMA key_join_priv CASCADE;
+DROP ROLE regress_key_join_priv_user;
+
+-- Key-join proof metadata must not affect query identifiers.  The same
+-- executable query should keep its queryId if an equivalent FK proof is
+-- dropped and recreated with new catalog OIDs.
+CREATE FUNCTION pg_temp.key_join_query_id(query_text text) RETURNS bigint
+LANGUAGE plpgsql AS $$
+DECLARE
+    line text;
+BEGIN
+    FOR line IN EXECUTE 'EXPLAIN (VERBOSE, FORMAT TEXT) ' || query_text
+    LOOP
+        IF line ~ 'Query Identifier:' THEN
+            RETURN regexp_replace(line, '.*Query Identifier:\s*(-?\d+).*', '\1')::bigint;
+        END IF;
+    END LOOP;
+    RAISE EXCEPTION 'Query Identifier not found in EXPLAIN output';
+END;
+$$;
+
+SET compute_query_id = on;
+CREATE TABLE key_join_queryid_parent
+(
+    id int PRIMARY KEY
+);
+CREATE TABLE key_join_queryid_child
+(
+    id int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES key_join_queryid_parent (id)
+);
+
+SELECT pg_temp.key_join_query_id($$
+SELECT *
+FROM key_join_queryid_parent p
+JOIN key_join_queryid_child c FOR KEY (parent_id) -> p (id);
+$$) AS qid \gset
+
+ALTER TABLE key_join_queryid_child DROP CONSTRAINT key_join_queryid_child_parent_id_fkey;
+ALTER TABLE key_join_queryid_child
+    ADD CONSTRAINT key_join_queryid_child_parent_id_fkey
+    FOREIGN KEY (parent_id) REFERENCES key_join_queryid_parent (id);
+
+SELECT pg_temp.key_join_query_id($$
+SELECT *
+FROM key_join_queryid_parent p
+JOIN key_join_queryid_child c FOR KEY (parent_id) -> p (id);
+$$) = :'qid'::bigint AS same_query_id_after_fk_recreate;
+
+RESET compute_query_id;
+DROP TABLE key_join_queryid_child, key_join_queryid_parent;
+
+-- ============================================================================
+-- Precise diagnostics for unprovable key joins: the error states which proof
+-- requirement is missing, and when the evidence existed but was discarded
+-- mid-query, why (and where, when locatable).
+-- ============================================================================
+CREATE SCHEMA key_join_diag;
+SET search_path = key_join_diag;
+CREATE TABLE kd_parent (id int PRIMARY KEY);
+CREATE TABLE kd_child (id int, parent_id int REFERENCES kd_parent (id));
+CREATE TABLE kd_fanout_child
+(
+    id int,
+    parent_id int NOT NULL REFERENCES kd_parent (id)
+);
+CREATE TABLE kd_unique_child
+(
+    id int,
+    parent_id int UNIQUE NOT NULL REFERENCES kd_parent (id)
+);
+
+-- Missing: no foreign key matches the referencing columns (id is not an FK).
+SELECT * FROM kd_parent p JOIN kd_child c FOR KEY (id) -> p (id);
+
+-- Inactivated: a derived referenced surface drops its row coverage at LIMIT.
+SELECT *
+FROM (SELECT id FROM kd_parent LIMIT 1) p
+JOIN kd_child c FOR KEY (parent_id) -> p (id);
+
+-- Inactivated by HAVING: the row-removing clause is named.
+SELECT *
+FROM (SELECT id FROM kd_parent GROUP BY id HAVING count(*) > 0) p
+JOIN kd_child c FOR KEY (parent_id) -> p (id);
+
+-- Inactivated uniqueness: the referenced surface may fan out.
+SELECT *
+FROM (
+    SELECT p.id
+    FROM kd_parent p
+    LEFT JOIN kd_fanout_child f FOR KEY (parent_id) -> p (id)
+) q
+JOIN kd_child c FOR KEY (parent_id) -> q (id);
+
+-- Inactivated row coverage: the referenced surface may lose rows.
+SELECT *
+FROM (
+    SELECT p.id
+    FROM kd_parent p
+    JOIN kd_unique_child u FOR KEY (parent_id) -> p (id)
+) q
+JOIN kd_child c FOR KEY (parent_id) -> q (id);
+
+-- Inactivated row coverage inside a view: name the view with the preceding join.
+CREATE VIEW kd_parent_joined AS
+SELECT p.id
+FROM kd_parent p
+JOIN kd_unique_child u FOR KEY (parent_id) -> p (id);
+
+SELECT *
+FROM kd_parent_joined p
+JOIN kd_child c FOR KEY (parent_id) -> p (id);
+
+DROP VIEW kd_parent_joined;
+
+-- Inactivated inside a view: the message names the view to inspect.
+CREATE VIEW kd_parent_limited AS SELECT id FROM kd_parent LIMIT 1;
+SELECT *
+FROM kd_parent_limited p
+JOIN kd_child c FOR KEY (parent_id) -> p (id);
+
+DROP VIEW kd_parent_limited;
+RESET search_path;
+DROP SCHEMA key_join_diag CASCADE;
+
+-- Final cleanup: drop all remaining objects
+DROP VIEW v1, v2;
+DROP TABLE shipments, orders, order_items, packages CASCADE;
+DROP TABLE customer_addresses CASCADE;
+DROP TABLE addresses, customers CASCADE;
+DROP TABLE t11, t10, t9, t8, t7, t6, t5, t4, t3, t2, t1 CASCADE;
diff --git a/src/test/regress/sql/key_join_icu.sql b/src/test/regress/sql/key_join_icu.sql
new file mode 100644
index 00000000000..55b583b104a
--- /dev/null
+++ b/src/test/regress/sql/key_join_icu.sql
@@ -0,0 +1,113 @@
+--
+-- key_join_icu
+--
+-- FOR KEY join cases that depend on an ICU nondeterministic collation.  Such a
+-- collation can only be created when the database encoding is one that ICU
+-- supports (e.g. UTF8), so these cases used to live inline in key_join.sql and
+-- key_join_mcdc.sql and broke the regression suite on SQL_ASCII databases and
+-- on builds without ICU.  Move them here and skip the whole file unless an ICU
+-- collation can actually be built; the rejection behaviour they exercise is
+-- collation-mechanism specific and not otherwise reachable without ICU.
+--
+
+/* skip test if not UTF8 server encoding or no ICU collations installed */
+SELECT getdatabaseencoding() <> 'UTF8' OR
+       (SELECT count(*) FROM pg_collation WHERE collprovider = 'i' AND collname <> 'unicode') = 0
+       AS skip_test \gset
+\if :skip_test
+\quit
+\endif
+
+CREATE SCHEMA key_join_icu;
+SET search_path = key_join_icu, public;
+
+-- Query-level DISTINCT over a nondeterministic collation cannot prove key
+-- uniqueness (originally in key_join.sql).
+CREATE COLLATION query_unique_nondet (provider = icu, locale = 'und',
+    deterministic = false);
+CREATE TABLE query_unique_nondet_parent
+(
+    id text COLLATE query_unique_nondet
+);
+CREATE TABLE query_unique_nondet_child
+(
+    parent_id text COLLATE query_unique_nondet
+);
+SELECT *
+FROM (SELECT DISTINCT id FROM query_unique_nondet_parent) p
+-- rejected, reason: nondeterministic DISTINCT collation cannot prove uniqueness
+JOIN query_unique_nondet_child c FOR KEY (parent_id) -> p (id);
+DROP TABLE query_unique_nondet_child, query_unique_nondet_parent;
+DROP COLLATION query_unique_nondet;
+
+-- A nondeterministic filter collation may disagree on equality, so a filtered
+-- referenced relation cannot be proven covered (originally in key_join.sql).
+CREATE TABLE filter_collation_parent
+(
+    code text COLLATE "C" PRIMARY KEY
+);
+CREATE TABLE filter_collation_child
+(
+    code text COLLATE "C" NOT NULL
+        REFERENCES filter_collation_parent (code)
+);
+
+INSERT INTO filter_collation_parent VALUES ('a'), ('b');
+INSERT INTO filter_collation_child VALUES ('a'), ('b');
+
+CREATE COLLATION filter_collation_nondet (provider = icu, locale = 'und',
+    deterministic = false);
+
+CREATE VIEW filter_collation_parent_nondet AS
+SELECT code FROM filter_collation_parent
+WHERE code = 'a'::text COLLATE filter_collation_nondet;
+
+CREATE VIEW filter_collation_child_nondet AS
+SELECT code FROM filter_collation_child
+WHERE code = 'a'::text COLLATE filter_collation_nondet;
+
+-- rejected, reason: nondeterministic filter collation may disagree on equality
+SELECT *
+FROM filter_collation_parent_nondet p
+JOIN filter_collation_child_nondet c FOR KEY (code) -> p (code);
+
+DROP VIEW filter_collation_child_nondet, filter_collation_parent_nondet;
+DROP COLLATION filter_collation_nondet;
+DROP TABLE filter_collation_child, filter_collation_parent;
+
+-- GROUP BY over a nondeterministic-collation key projects no key facts, so the
+-- key join cannot be proven (originally in key_join_mcdc.sql, which runs with
+-- terse error verbosity).
+\set VERBOSITY terse
+CREATE COLLATION mcdc_nondet (provider = icu, locale = 'und',
+    deterministic = false);
+
+CREATE TABLE mcdc_text_parent
+(
+    id text COLLATE mcdc_nondet PRIMARY KEY
+);
+
+CREATE TABLE mcdc_text_reader
+(
+    id        int PRIMARY KEY,
+    parent_id text COLLATE mcdc_nondet NOT NULL
+        REFERENCES mcdc_text_parent (id)
+);
+
+INSERT INTO mcdc_text_parent VALUES ('a'), ('b');
+INSERT INTO mcdc_text_reader VALUES (301, 'a'), (302, 'b');
+
+SELECT tr.id, g.id
+FROM (
+    SELECT p.id
+    FROM mcdc_text_parent p
+    GROUP BY p.id
+) g
+JOIN mcdc_text_reader tr FOR KEY (parent_id) -> g (id)
+ORDER BY tr.id;
+
+DROP TABLE mcdc_text_reader, mcdc_text_parent;
+DROP COLLATION mcdc_nondet;
+\set VERBOSITY default
+
+DROP SCHEMA key_join_icu;
diff --git a/src/test/regress/sql/key_join_matched_filter.sql b/src/test/regress/sql/key_join_matched_filter.sql
new file mode 100644
index 00000000000..c84f8dc2c1e
--- /dev/null
+++ b/src/test/regress/sql/key_join_matched_filter.sql
@@ -0,0 +1,999 @@
+--
+-- Test the matched-filter rule for FOR KEY joins through derived tables.
+--
+-- A derived table whose WHERE filters its rows is "row-preserving modulo
+-- that filter".  A key join may use such a derived table on the
+-- referenced (PK) side when the referencing (FK) side applies a matching
+-- direct key equality filter on the corresponding key columns.  The value
+-- side must still match structurally after constant folding and FK-mapped Var
+-- substitution, and the filter must use the exact key identity for that FK
+-- position.
+--
+
+CREATE SCHEMA mf;
+SET search_path = mf, public;
+
+-- ===========================================================================
+-- Multi-tenant schema: composite FK, both sides parameterised by tenant_id.
+-- ===========================================================================
+
+CREATE TABLE customers (
+    tenant_id INTEGER NOT NULL,
+    id        INTEGER NOT NULL,
+    name      TEXT    NOT NULL,
+    active    BOOLEAN NOT NULL DEFAULT true,
+    PRIMARY KEY (tenant_id, id)
+);
+
+CREATE TABLE orders (
+    tenant_id   INTEGER NOT NULL,
+    id          INTEGER NOT NULL,
+    customer_id INTEGER NOT NULL,
+    amount      NUMERIC NOT NULL,
+    PRIMARY KEY (tenant_id, id),
+    FOREIGN KEY (tenant_id, customer_id) REFERENCES customers (tenant_id, id)
+);
+
+INSERT INTO customers VALUES
+    (1, 100, 'Alice',  true),
+    (1, 101, 'Bob',    false),
+    (2, 100, 'Carol',  true),
+    (2, 200, 'Dave',   true);
+INSERT INTO orders VALUES
+    (1, 1, 100, 9.99),
+    (1, 2, 101, 19.99),
+    (2, 1, 100,  4.50),
+    (2, 2, 200, 49.00);
+
+CREATE VIEW tenant_customers AS
+    SELECT * FROM customers
+    WHERE tenant_id = current_setting('mf.tenant')::int;
+
+CREATE VIEW tenant_orders AS
+    SELECT * FROM orders
+    WHERE tenant_id = current_setting('mf.tenant')::int;
+
+SET mf.tenant = '1';
+
+-- M1: Canonical multi-tenant case -- accepted
+SELECT o.id, c.name
+FROM tenant_orders o
+JOIN tenant_customers c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id)
+ORDER BY o.id;
+
+-- M1b: Matched filters and FK columns may use non-first direct aliases -- accepted
+CREATE VIEW customer_aliases AS
+    SELECT tenant_id AS tenant_id_1, tenant_id AS tenant_id_2,
+           id AS id_1, id AS id_2, name
+    FROM customers;
+CREATE VIEW order_aliases AS
+    SELECT tenant_id AS tenant_id_1, tenant_id AS tenant_id_2,
+           id, customer_id AS customer_id_1, customer_id AS customer_id_2,
+           amount
+    FROM orders;
+CREATE VIEW tenant_customers_dup AS
+    SELECT * FROM customer_aliases
+    WHERE tenant_id_2 = current_setting('mf.tenant')::int;
+CREATE VIEW tenant_orders_dup AS
+    SELECT * FROM order_aliases
+    WHERE tenant_id_2 = current_setting('mf.tenant')::int;
+
+SELECT o.id, c.name
+FROM tenant_orders_dup o
+JOIN tenant_customers_dup c
+  FOR KEY (tenant_id_2, id_2) <- o (tenant_id_2, customer_id_2)
+ORDER BY o.id;
+
+DROP VIEW tenant_orders_dup, tenant_customers_dup, order_aliases, customer_aliases;
+
+-- M1c: Same-domain key filters normalize through the base equality type -- accepted
+CREATE DOMAIN tenant_domain AS integer;
+
+CREATE TABLE domain_customers (
+    tenant_id tenant_domain NOT NULL,
+    id        INTEGER       NOT NULL,
+    name      TEXT          NOT NULL,
+    PRIMARY KEY (tenant_id, id)
+);
+
+CREATE TABLE domain_orders (
+    tenant_id   tenant_domain NOT NULL,
+    id          INTEGER       NOT NULL,
+    customer_id INTEGER       NOT NULL,
+    amount      NUMERIC       NOT NULL,
+    PRIMARY KEY (tenant_id, id),
+    FOREIGN KEY (tenant_id, customer_id)
+        REFERENCES domain_customers (tenant_id, id)
+);
+
+INSERT INTO domain_customers VALUES
+    (1, 100, 'Adele'),
+    (1, 101, 'Bert'),
+    (2, 100, 'Cleo');
+INSERT INTO domain_orders VALUES
+    (1, 1, 100, 8.25),
+    (1, 2, 101, 6.50),
+    (2, 1, 100, 4.75);
+
+CREATE VIEW domain_tenant_customers AS
+    SELECT * FROM domain_customers
+    WHERE tenant_id = 1;
+
+CREATE VIEW domain_tenant_orders AS
+    SELECT * FROM domain_orders
+    WHERE tenant_id = 1;
+
+SELECT o.id, c.name
+FROM domain_tenant_orders o
+JOIN domain_tenant_customers c
+  FOR KEY (tenant_id, id) <- o (tenant_id, customer_id)
+ORDER BY o.id;
+
+DROP VIEW domain_tenant_orders, domain_tenant_customers;
+DROP TABLE domain_orders, domain_customers;
+DROP DOMAIN tenant_domain;
+
+-- Sanity: matches the explicit base-table form.
+SELECT o.id, c.name
+FROM orders o
+JOIN customers c
+  ON c.tenant_id = o.tenant_id AND c.id = o.customer_id
+WHERE o.tenant_id = current_setting('mf.tenant')::int
+  AND c.tenant_id = current_setting('mf.tenant')::int
+ORDER BY o.id;
+
+-- M2: FK side missing the filter -- rejected, reason: referenced filter is not matched on the referencing side
+SELECT o.id, c.name
+FROM orders o
+JOIN tenant_customers c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+
+-- M2b: Join-local FILTER on the referencing key does not supply operand
+-- matched-filter evidence -- rejected.
+SELECT o.id, c.name
+FROM orders o
+JOIN tenant_customers c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id)
+FILTER (WHERE o.tenant_id = current_setting('mf.tenant')::int);
+
+-- M2c: Join-local FILTER on the referenced key also does not supply operand
+-- matched-filter evidence -- rejected.
+SELECT o.id, c.name
+FROM orders o
+JOIN tenant_customers c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id)
+FILTER (WHERE c.tenant_id = current_setting('mf.tenant')::int);
+
+-- M3: Different filter expressions (different constants) -- rejected, reason: referenced and referencing filters are not equivalent
+CREATE VIEW tenant_customers_2 AS
+    SELECT * FROM customers WHERE tenant_id = 2;
+CREATE VIEW tenant_orders_1 AS
+    SELECT * FROM orders WHERE tenant_id = 1;
+
+SELECT o.id, c.name
+FROM tenant_orders_1 o
+-- rejected, reason: referenced and referencing filters use different constants
+JOIN tenant_customers_2 c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+
+DROP VIEW tenant_customers_2, tenant_orders_1;
+
+-- M4: Composite filter on both sides matching FK composite columns -- accepted
+CREATE VIEW c_pinpoint AS
+    SELECT * FROM customers WHERE tenant_id = 1 AND id = 100;
+CREATE VIEW o_pinpoint AS
+    SELECT * FROM orders    WHERE tenant_id = 1 AND customer_id = 100;
+
+SELECT o.id, c.name
+FROM o_pinpoint o
+-- accepted
+JOIN c_pinpoint c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id)
+ORDER BY o.id;
+
+DROP VIEW c_pinpoint, o_pinpoint;
+
+-- M5: Volatile filter -- rejected, reason: PK side filter contains random().
+CREATE VIEW c_volatile AS
+    SELECT * FROM customers WHERE tenant_id = (random()*100)::int;
+CREATE VIEW o_volatile AS
+    SELECT * FROM orders    WHERE tenant_id = (random()*100)::int;
+
+SELECT o.id
+FROM o_volatile o
+-- rejected, reason: referenced-side filter contains volatile random()
+JOIN c_volatile c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+
+DROP VIEW c_volatile, o_volatile;
+
+-- M6: Filter on a non-key column on the PK side -- rejected, reason: active is not part of the FK mapping
+-- The PK conjunct on `active` references a column that isn't part of the
+-- FK, so the conjunct cannot be carried across the FK mapping at all.
+CREATE VIEW active_customers AS
+    SELECT * FROM customers WHERE active;
+CREATE VIEW any_orders AS
+    SELECT * FROM orders WHERE amount > 0;
+
+SELECT o.id, c.name
+FROM any_orders o
+-- rejected, reason: referenced-side filter is on a non-key column
+JOIN active_customers c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+
+DROP VIEW active_customers, any_orders;
+
+-- M6b: OR on the referenced side -- rejected, reason: OR is not a safe
+-- key-filter conjunct for referenced-side rowCoverage.
+CREATE VIEW c_or_referenced AS
+    SELECT * FROM customers
+    WHERE tenant_id = current_setting('mf.tenant')::int OR active;
+
+SELECT o.id, c.name
+FROM tenant_orders o
+-- rejected, reason: referenced-side OR filter drops rowCoverage
+JOIN c_or_referenced c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+
+DROP VIEW c_or_referenced;
+
+-- M6c: OR on the referencing side -- rejected, reason: the referencing
+-- FK fact must not retain tenant_id evidence from inside an OR.
+CREATE VIEW o_or_referencing AS
+    SELECT * FROM orders
+    WHERE tenant_id = current_setting('mf.tenant')::int OR amount = 2;
+
+SELECT o.id, c.name
+FROM o_or_referencing o
+-- rejected, reason: referencing-side OR filter does not match referenced filter
+JOIN tenant_customers c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+
+DROP VIEW o_or_referencing;
+
+-- M6d: Matching-looking OR on both sides -- rejected, reason: OR is not a
+-- safe matched-filter conjunct even when the same shape appears on both sides.
+CREATE VIEW c_or_both AS
+    SELECT * FROM customers
+    WHERE tenant_id = current_setting('mf.tenant')::int OR id = 100;
+CREATE VIEW o_or_both AS
+    SELECT * FROM orders
+    WHERE tenant_id = current_setting('mf.tenant')::int OR customer_id = 100;
+
+SELECT o.id, c.name
+FROM o_or_both o
+-- rejected, reason: OR filters are not retained as matched-filter evidence
+JOIN c_or_both c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+
+DROP VIEW o_or_both, c_or_both;
+
+-- M6e: A disjunction nested under a top-level AND is still not a
+-- conjunct the proof system can retain, even when both sides have the
+-- same mapped Boolean shape.
+CREATE VIEW c_or_nested AS
+    SELECT * FROM customers
+    WHERE tenant_id = current_setting('mf.tenant')::int
+      AND (id = 100 OR tenant_id = 2);
+CREATE VIEW o_or_nested AS
+    SELECT * FROM orders
+    WHERE tenant_id = current_setting('mf.tenant')::int
+      AND (customer_id = 100 OR tenant_id = 2);
+
+SELECT o.id, c.name
+FROM o_or_nested o
+-- rejected, reason: nested Boolean filters are not retained as matched-filter evidence
+JOIN c_or_nested c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+
+DROP VIEW o_or_nested, c_or_nested;
+
+-- M7: Two-layer view, filter in the inner layer -- accepted
+CREATE VIEW c_inner AS
+    SELECT * FROM customers
+    WHERE tenant_id = current_setting('mf.tenant')::int;
+CREATE VIEW c_outer AS
+    SELECT * FROM c_inner;
+CREATE VIEW o_inner AS
+    SELECT * FROM orders
+    WHERE tenant_id = current_setting('mf.tenant')::int;
+CREATE VIEW o_outer AS
+    SELECT * FROM o_inner;
+
+SELECT o.id, c.name
+FROM o_outer o
+-- accepted
+JOIN c_outer c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id)
+ORDER BY o.id;
+
+DROP VIEW c_outer, c_inner, o_outer, o_inner;
+
+-- M8: CTE on each side -- accepted
+WITH
+    co AS (SELECT * FROM customers WHERE tenant_id = current_setting('mf.tenant')::int),
+    oo AS (SELECT * FROM orders    WHERE tenant_id = current_setting('mf.tenant')::int)
+SELECT o.id, c.name
+FROM oo o
+JOIN co c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id)
+ORDER BY o.id;
+
+-- M9: Matched filter on PK side, FK side has the matched filter PLUS an extra
+-- conjunct that doesn't reference FK columns -- accepted (PK-driven matching).
+CREATE VIEW big_orders AS
+    SELECT * FROM orders
+    WHERE tenant_id = current_setting('mf.tenant')::int
+      AND amount > 5;
+
+SELECT o.id, c.name
+FROM big_orders o
+-- accepted
+JOIN tenant_customers c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id)
+ORDER BY o.id;
+
+DROP VIEW big_orders;
+
+-- M10: Matched filter on PK side has an extra conjunct that the FK side does
+-- not -- rejected, reason: referenced-side active conjunct is not matched (strict p_PK = p_match: every referenced-side conjunct
+-- must match).
+CREATE VIEW strict_customers AS
+    SELECT * FROM customers
+    WHERE tenant_id = current_setting('mf.tenant')::int
+      AND active;
+
+SELECT o.id, c.name
+FROM tenant_orders o
+-- rejected, reason: referenced side has an unmatched active filter
+JOIN strict_customers c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+
+DROP VIEW strict_customers;
+
+-- M11: System column on referenced side -- rejected, reason: system-column filters are not matched key filters (would have
+-- crashed before the varattno > 0 guard, since bms_is_member errors on
+-- negative members).
+CREATE VIEW c_sys AS
+    SELECT * FROM customers WHERE ctid IS NOT NULL;
+CREATE VIEW o_sys AS
+    SELECT * FROM orders    WHERE ctid IS NOT NULL;
+
+SELECT o.id
+FROM o_sys o
+-- rejected, reason: referenced-side filter uses a system column
+JOIN c_sys c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+
+DROP VIEW c_sys, o_sys;
+
+-- M12: Parameterised filters are not retained as matched-filter evidence.
+PREPARE mf_p AS
+    SELECT o.id, c.name
+    FROM (SELECT * FROM orders    WHERE tenant_id = $1) o
+    JOIN (SELECT * FROM customers WHERE tenant_id = $1) c
+-- rejected, reason: parameterized filters are not matched-filter evidence
+        FOR KEY (tenant_id, id) <- o (tenant_id, customer_id)
+    ORDER BY o.id;
+
+-- M12b: Asymmetric parameterised filter — referenced side has $1, the
+-- referencing side has a constant value → rejected for the same reason.
+PREPARE mf_p_bad AS
+    SELECT o.id, c.name
+    FROM (SELECT * FROM orders    WHERE tenant_id = 1) o
+    JOIN (SELECT * FROM customers WHERE tenant_id = $1) c
+-- rejected, reason: referenced-side parameterized filter is not proof evidence
+        FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+
+-- M12c: Proof placeholders must not collide with real SQL parameters.
+-- The referenced-side $1 used to be remapped as if it were the tenant_id
+-- key placeholder, making customer_id = tenant_id look like it matched
+-- id = $1.
+CREATE TABLE param_customers (
+    tenant_id INTEGER NOT NULL,
+    id        INTEGER NOT NULL,
+    name      TEXT    NOT NULL,
+    PRIMARY KEY (tenant_id, id)
+);
+CREATE TABLE param_orders (
+    tenant_id   INTEGER NOT NULL,
+    id          INTEGER NOT NULL,
+    customer_id INTEGER NOT NULL,
+    PRIMARY KEY (tenant_id, id),
+    FOREIGN KEY (tenant_id, customer_id) REFERENCES param_customers (tenant_id, id)
+);
+INSERT INTO param_customers VALUES (1, 1, 'One'), (1, 2, 'Two');
+INSERT INTO param_orders VALUES (1, 1, 1);
+
+PREPARE mf_p_collision(int, int) AS
+    SELECT o.id, c.name
+    FROM (SELECT * FROM param_orders WHERE customer_id = tenant_id) o
+    JOIN (SELECT * FROM param_customers WHERE id = $1) c
+-- rejected, reason: referenced-side SQL parameter is not proof evidence
+        FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+
+DROP TABLE param_orders, param_customers;
+
+-- M13: current_user inside a STABLE-driven filter on a column that IS
+-- in the FK -- accepted. Adds a tenant_role column to both sides that
+-- gets its value from the user, with a tenant_id-shaped FK so the
+-- matched filter is on a column the FK column mapping covers.
+DROP VIEW tenant_orders, tenant_customers;
+ALTER TABLE customers ADD COLUMN owner_role NAME NOT NULL DEFAULT current_user;
+ALTER TABLE orders    ADD COLUMN owner_role NAME NOT NULL DEFAULT current_user;
+
+-- Extend the existing FK so owner_role is one of the matched columns.
+ALTER TABLE orders    DROP CONSTRAINT orders_tenant_id_customer_id_fkey;
+ALTER TABLE customers DROP CONSTRAINT customers_pkey;
+ALTER TABLE customers ADD CONSTRAINT customers_pkey
+    PRIMARY KEY (tenant_id, owner_role, id);
+ALTER TABLE orders    ADD FOREIGN KEY (tenant_id, owner_role, customer_id)
+    REFERENCES customers (tenant_id, owner_role, id);
+
+CREATE VIEW c_mine AS
+    SELECT * FROM customers WHERE owner_role = current_user;
+CREATE VIEW o_mine AS
+    SELECT * FROM orders    WHERE owner_role = current_user;
+
+SELECT o.id, c.name
+FROM o_mine o
+-- accepted
+JOIN c_mine c FOR KEY (tenant_id, owner_role, id)
+              <- o (tenant_id, owner_role, customer_id)
+ORDER BY o.id, c.tenant_id;
+
+DROP VIEW c_mine, o_mine;
+-- Restore the original schema for subsequent cases.
+ALTER TABLE orders    DROP CONSTRAINT orders_tenant_id_owner_role_customer_id_fkey;
+ALTER TABLE customers DROP CONSTRAINT customers_pkey;
+ALTER TABLE customers ADD CONSTRAINT customers_pkey PRIMARY KEY (tenant_id, id);
+ALTER TABLE orders    ADD FOREIGN KEY (tenant_id, customer_id)
+    REFERENCES customers (tenant_id, id);
+ALTER TABLE customers DROP COLUMN owner_role;
+ALTER TABLE orders    DROP COLUMN owner_role;
+CREATE VIEW tenant_customers AS
+    SELECT * FROM customers WHERE tenant_id = current_setting('mf.tenant')::int;
+CREATE VIEW tenant_orders AS
+    SELECT * FROM orders    WHERE tenant_id = current_setting('mf.tenant')::int;
+
+-- M14: Inequality filter on both sides -- rejected, reason: matched filters
+-- must be direct equality predicates.
+-- Arbitrary inequalities are not accepted just because their expression
+-- trees match.
+CREATE VIEW c_ineq AS SELECT * FROM customers WHERE tenant_id < 10;
+CREATE VIEW o_ineq AS SELECT * FROM orders    WHERE tenant_id < 10;
+
+SELECT o.id, c.name
+FROM o_ineq o
+-- rejected, reason: matched filters must be direct equality predicates
+JOIN c_ineq c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id)
+ORDER BY o.id;
+
+DROP VIEW c_ineq, o_ineq;
+
+-- M15: Nested key join through conditionally row-preserved derived tables.
+-- The inner key join (payments → orders) accepts under the
+-- matched-filter rule.  The outer key join (orders → customers)
+-- should also accept by propagating the matched row-coverage chain
+-- from payments through orders to customers.
+CREATE TABLE payments (
+    tenant_id INTEGER NOT NULL,
+    id        INTEGER NOT NULL,
+    order_id  INTEGER NOT NULL,
+    amount    NUMERIC NOT NULL,
+    PRIMARY KEY (tenant_id, id),
+    FOREIGN KEY (tenant_id, order_id) REFERENCES orders (tenant_id, id)
+);
+
+INSERT INTO payments VALUES
+    (1, 9001, 1, 9.99),
+    (1, 9002, 2, 19.99),
+    (2, 9001, 1, 4.50);
+
+CREATE VIEW tenant_payments AS
+    SELECT * FROM payments
+    WHERE tenant_id = current_setting('mf.tenant')::int;
+
+SELECT o.id AS order_id, c.name AS customer_name, p.amount
+FROM tenant_payments p
+JOIN tenant_orders o
+-- accepted
+    FOR KEY (tenant_id, id) <- p (tenant_id, order_id)
+JOIN tenant_customers c
+-- accepted
+    FOR KEY (tenant_id, id) <- o (tenant_id, customer_id)
+ORDER BY p.id;
+
+-- M15b: The middle orders input is unfiltered.  The first key join
+-- proves p.tenant_id = o.tenant_id for rows in the result, so the
+-- tenant_payments filter is materialized onto the output orders FK fact
+-- and can satisfy tenant_customers in the second key join.
+SELECT o.id AS order_id, c.name AS customer_name, p.amount
+FROM tenant_payments p
+JOIN orders o
+-- accepted
+    FOR KEY (tenant_id, id) <- p (tenant_id, order_id)
+JOIN tenant_customers c
+-- accepted
+    FOR KEY (tenant_id, id) <- o (tenant_id, customer_id)
+ORDER BY p.id;
+
+DROP VIEW tenant_payments;
+DROP TABLE payments;
+
+-- M16: Multi-layer view, filter only at the OUTER layer -- accepted
+-- The outer WHERE references the inner view's RTE; absorption translates
+-- it through the inner view's targetlist (a SELECT * passthrough) into
+-- the leaf base table's frame.
+CREATE VIEW c_passthrough AS SELECT * FROM customers;
+CREATE VIEW c_outer_filter AS
+    SELECT * FROM c_passthrough WHERE tenant_id = 1;
+CREATE VIEW o_t1 AS
+    SELECT * FROM orders WHERE tenant_id = 1;
+
+SELECT o.id, c.name
+FROM o_t1 o
+-- accepted
+JOIN c_outer_filter c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id)
+ORDER BY o.id, c.id;
+
+DROP VIEW o_t1, c_outer_filter, c_passthrough;
+
+-- M17: Multi-layer view with filters at BOTH layers -- accepted when
+-- the referencing side applies the conjunction of both filters.
+CREATE VIEW c_inner_tenant AS
+    SELECT * FROM customers WHERE tenant_id = 1;
+CREATE VIEW c_outer_id AS
+    SELECT * FROM c_inner_tenant WHERE id = 100;
+CREATE VIEW o_combined AS
+    SELECT * FROM orders WHERE tenant_id = 1 AND customer_id = 100;
+
+SELECT o.id, c.name
+FROM o_combined o
+-- accepted
+JOIN c_outer_id c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+
+DROP VIEW o_combined;
+
+-- M18: Multi-layer view with filters at BOTH layers, but the referencing
+-- side filters the FK column to a different value -- rejected, reason: referencing-side filter maps to a different key value.
+-- Both the inner WHERE (tenant_id = 1) and outer WHERE (id = 100) must be
+-- captured as matched-filter evidence; otherwise the join would accept while
+-- silently dropping rows at runtime.
+CREATE VIEW o_mismatch AS
+    SELECT * FROM orders WHERE tenant_id = 1 AND customer_id = 999;
+
+SELECT o.id, c.name
+FROM o_mismatch o
+-- rejected, reason: referencing side filters the FK column to a different value
+JOIN c_outer_id c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+
+DROP VIEW o_mismatch, c_outer_id, c_inner_tenant;
+
+-- M19: Multi-layer view with LIMIT at the outer layer — hard disqualifier
+-- at the absorbing layer, drops referenced-side rowCoverage entirely.
+CREATE VIEW c_inner_for_lim AS
+    SELECT * FROM customers WHERE tenant_id = 1;
+CREATE VIEW c_outer_lim AS
+    SELECT * FROM c_inner_for_lim LIMIT 5;
+
+SELECT o.id, c.name
+FROM tenant_orders o
+-- rejected, reason: referenced-side outer view has LIMIT
+JOIN c_outer_lim c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+
+DROP VIEW c_outer_lim, c_inner_for_lim;
+
+-- M20: Multi-layer view with a SubLink in the outer WHERE — the conjunct
+-- cannot be represented as a safe key conjunct after Var translation, so the
+-- referenced-side rowCoverage fact is dropped.
+CREATE VIEW c_inner_for_sub AS
+    SELECT * FROM customers WHERE tenant_id = 1;
+CREATE VIEW c_outer_sub AS
+    SELECT * FROM c_inner_for_sub WHERE EXISTS (SELECT 1);
+
+SELECT o.id, c.name
+FROM tenant_orders o
+-- rejected, reason: referenced-side outer WHERE contains an unverifiable sublink
+JOIN c_outer_sub c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+
+DROP VIEW c_outer_sub, c_inner_for_sub;
+
+-- M21: NextValueExpr (volatile, sequence-accessing) in a referenced-side
+-- WHERE.  The capture-time contain_volatile_functions check flags this and
+-- the entry is rejected as "filters this rule cannot verify".  Without that
+-- check, two views carrying the structurally-identical nextval expression
+-- would equal()-match in try_matched_filter and be unsoundly accepted, even
+-- though their runtime evaluations would draw different sequence values.
+CREATE SEQUENCE mf_seq;
+CREATE VIEW c_nextval AS
+    SELECT * FROM customers WHERE id = nextval('mf_seq')::int;
+CREATE VIEW o_nextval AS
+    SELECT * FROM orders    WHERE customer_id = nextval('mf_seq')::int;
+
+SELECT o.id
+FROM o_nextval o
+-- rejected, reason: referenced-side filter contains volatile nextval()
+JOIN c_nextval c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+
+DROP VIEW c_nextval, o_nextval;
+DROP SEQUENCE mf_seq;
+
+-- M21b: User-defined same-name equality operators are not FK equality
+-- evidence -- rejected, reason: matched-filter evidence must use the FK
+-- position's common catalog equality operator.
+CREATE FUNCTION mf_op_eq(integer, integer) RETURNS boolean
+    LANGUAGE sql STABLE AS $$ SELECT $1 OPERATOR(pg_catalog.=) $2 $$;
+CREATE OPERATOR = (
+    LEFTARG = integer,
+    RIGHTARG = integer,
+    FUNCTION = mf_op_eq
+);
+
+CREATE VIEW op_customers AS
+    SELECT * FROM customers WHERE tenant_id OPERATOR(mf.=) 1;
+CREATE VIEW op_orders AS
+    SELECT * FROM orders WHERE tenant_id OPERATOR(mf.=) 1;
+
+SELECT o.id, c.name
+FROM op_orders o
+-- rejected, reason: mf.= is not the FK equality operator
+JOIN op_customers c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+
+-- M21c: Propagated user-defined equality evidence is still rejected at the
+-- consuming key join.
+CREATE TABLE op_payments (
+    tenant_id integer NOT NULL,
+    id        integer NOT NULL,
+    order_id  integer NOT NULL,
+    PRIMARY KEY (tenant_id, id),
+    FOREIGN KEY (tenant_id, order_id) REFERENCES orders (tenant_id, id)
+);
+INSERT INTO op_payments VALUES (1, 1, 1), (1, 2, 2), (2, 1, 1);
+CREATE VIEW op_payments_v AS
+    SELECT * FROM op_payments WHERE tenant_id OPERATOR(mf.=) 1;
+
+SELECT p.id AS payment_id, o.id AS order_id, c.name
+FROM op_payments_v p
+JOIN orders o FOR KEY (tenant_id, id) <- p (tenant_id, order_id)
+-- rejected, reason: propagated mf.= evidence is not FK equality evidence
+JOIN op_customers c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+
+DROP VIEW op_payments_v, op_orders, op_customers;
+DROP TABLE op_payments;
+DROP OPERATOR mf.= (integer, integer);
+DROP FUNCTION mf_op_eq(integer, integer);
+
+-- M22: View body with a non-key JOIN -- caught by the absence of exported
+-- rowCoverage for arbitrary ordinary joins.  Only accepted key joins and the
+-- single-row companion cross-join case export facts to later key joins.
+CREATE VIEW c_join AS
+    SELECT a.tenant_id, a.id, a.name, a.active, b.amount
+    FROM customers a
+    JOIN orders b ON a.tenant_id = b.tenant_id AND a.id = b.customer_id
+    WHERE a.tenant_id = 1;
+
+SELECT o.id, c.name
+FROM tenant_orders o
+-- rejected, reason: referenced view body contains a non-key join
+JOIN c_join c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+
+DROP VIEW c_join;
+
+-- M23: View with set-returning function in target list.  hasTargetSRFs is
+-- caught by drill_down_to_base_rel_query at FK validation; this exercises
+-- that the existing rejection still fires after the soundness changes.
+CREATE VIEW c_srf AS
+    SELECT generate_series(1, 5) AS gs, c.* FROM customers c WHERE tenant_id = 1;
+
+SELECT o.id, c.name
+FROM tenant_orders o
+-- rejected, reason: referenced view target list contains a set-returning function
+JOIN c_srf c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+
+DROP VIEW c_srf;
+
+-- M24: Direct equality predicate forms -- accepted.
+CREATE VIEW c_types AS
+    SELECT * FROM customers
+    WHERE tenant_id = 1;
+CREATE VIEW o_types AS
+    SELECT * FROM orders
+    WHERE tenant_id = 1;
+
+SELECT o.id, c.name
+FROM o_types o
+-- accepted
+JOIN c_types c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id)
+ORDER BY o.id;
+
+DROP VIEW o_types, c_types;
+
+-- M24b: Richer predicate forms are no longer retained as proof evidence --
+-- rejected, reason: matched-filter evidence is direct key equality only.
+CREATE VIEW c_types AS
+    SELECT * FROM customers
+    WHERE tenant_id = 1
+      AND tenant_id <> 99
+      AND tenant_id IN (1, 2)
+      AND tenant_id NOT IN (99, 100)
+      AND tenant_id IS NOT NULL
+      AND NOT (tenant_id = 99)
+      AND tenant_id IS DISTINCT FROM 99
+      AND (tenant_id = 1) IS TRUE;
+CREATE VIEW o_types AS
+    SELECT * FROM orders
+    WHERE tenant_id = 1
+      AND tenant_id <> 99
+      AND tenant_id IN (1, 2)
+      AND tenant_id NOT IN (99, 100)
+      AND tenant_id IS NOT NULL
+      AND NOT (tenant_id = 99)
+      AND tenant_id IS DISTINCT FROM 99
+      AND (tenant_id = 1) IS TRUE;
+
+SELECT o.id, c.name
+FROM o_types o
+-- rejected, reason: richer predicates are not direct equality evidence
+JOIN c_types c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id)
+ORDER BY o.id;
+
+DROP VIEW o_types, c_types;
+
+-- M25: Key-column transformations -- rejected, reason: immutable functions can
+-- distinguish values that the FK equality operator treats as equal, so a
+-- structurally matching transformed-key predicate is not a safe matched filter.
+CREATE VIEW c_transformed AS
+    SELECT * FROM customers
+    WHERE abs(tenant_id) = 1;
+CREATE VIEW o_transformed AS
+    SELECT * FROM orders
+    WHERE abs(tenant_id) = 1;
+
+SELECT o.id
+FROM o_transformed o
+-- rejected, reason: matched filter transforms the FK column
+JOIN c_transformed c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+
+DROP VIEW o_transformed, c_transformed;
+
+-- M26: Numeric transformed-key counterexample -- rejected, reason: PostgreSQL
+-- numeric equality treats 1.0 and 1.00 as equal, but scale(numeric) sees
+-- different display scales; accepting this matched filter would drop the
+-- preserved child row.
+CREATE TABLE numeric_parent (
+    id numeric PRIMARY KEY
+);
+CREATE TABLE numeric_child (
+    id        integer PRIMARY KEY,
+    parent_id numeric NOT NULL REFERENCES numeric_parent (id)
+);
+
+INSERT INTO numeric_parent VALUES (1.0);
+INSERT INTO numeric_child VALUES (1, 1.00);
+
+CREATE VIEW numeric_parent_scale2 AS
+    SELECT * FROM numeric_parent WHERE scale(id) = 2;
+CREATE VIEW numeric_child_scale2 AS
+    SELECT * FROM numeric_child WHERE scale(parent_id) = 2;
+
+SELECT c.id, p.id
+FROM numeric_child_scale2 c
+-- rejected, reason: scale() can distinguish values considered equal by FK equality
+JOIN numeric_parent_scale2 p FOR KEY (id) <- c (parent_id);
+
+SELECT id, parent_id FROM numeric_child_scale2;
+
+CREATE VIEW numeric_parent_text AS
+    SELECT * FROM numeric_parent WHERE id::text = '1.00';
+CREATE VIEW numeric_child_text AS
+    SELECT * FROM numeric_child WHERE parent_id::text = '1.00';
+
+SELECT id, parent_id FROM numeric_child_text;
+
+SELECT c.id, p.id
+FROM numeric_child_text c
+-- rejected, reason: text coercion can distinguish values considered equal by FK equality
+JOIN numeric_parent_text p FOR KEY (id) <- c (parent_id);
+
+DROP VIEW numeric_child_text, numeric_parent_text;
+DROP VIEW numeric_child_scale2, numeric_parent_scale2;
+DROP TABLE numeric_child, numeric_parent;
+
+-- M26b: Numeric custom-operator counterexample -- rejected, reason: a
+-- user-defined numeric equality filter can distinguish values that the FK
+-- equality operator treats as equal.
+CREATE FUNCTION numeric_text_eq(numeric, numeric) RETURNS boolean
+    LANGUAGE sql IMMUTABLE AS $$ SELECT $1::text OPERATOR(pg_catalog.=) $2::text $$;
+CREATE OPERATOR = (
+    LEFTARG = numeric,
+    RIGHTARG = numeric,
+    FUNCTION = numeric_text_eq
+);
+
+CREATE TABLE numeric_op_parent (
+    id numeric PRIMARY KEY
+);
+CREATE TABLE numeric_op_child (
+    id        integer PRIMARY KEY,
+    parent_id numeric NOT NULL REFERENCES numeric_op_parent (id)
+);
+
+INSERT INTO numeric_op_parent VALUES (1.0);
+INSERT INTO numeric_op_child VALUES (1, 1.00);
+
+CREATE VIEW numeric_op_parent_v AS
+    SELECT * FROM numeric_op_parent WHERE id OPERATOR(mf.=) 1.00;
+CREATE VIEW numeric_op_child_v AS
+    SELECT * FROM numeric_op_child WHERE parent_id OPERATOR(mf.=) 1.00;
+
+SELECT id, parent_id FROM numeric_op_child_v;
+SELECT id FROM numeric_op_parent_v;
+
+SELECT c.id, p.id
+FROM numeric_op_child_v c
+-- rejected, reason: mf.= is not the FK equality operator
+JOIN numeric_op_parent_v p FOR KEY (id) <- c (parent_id);
+
+DROP VIEW numeric_op_child_v, numeric_op_parent_v;
+DROP TABLE numeric_op_child, numeric_op_parent;
+DROP OPERATOR mf.= (numeric, numeric);
+DROP FUNCTION numeric_text_eq(numeric, numeric);
+
+-- M27: Currently-unsupported compound expression node types -- rejected, reason:
+-- CollateExpr is not direct key equality.
+-- This conjunct wraps the FK column tenant_id in an explicit COLLATE,
+-- which produces a CollateExpr.  CollateExpr is not on the
+-- direct-equality evidence path.  The default-reject policy correctly rejects
+-- this conjunct as having a filter the rule cannot verify.
+CREATE VIEW c_unsupported AS
+    SELECT * FROM customers
+    WHERE (tenant_id::text COLLATE "C") = '1';
+CREATE VIEW o_unsupported AS
+    SELECT * FROM orders
+    WHERE (tenant_id::text COLLATE "C") = '1';
+
+SELECT o.id
+FROM o_unsupported o
+-- rejected, reason: collated expression is not supported for matched filters
+JOIN c_unsupported c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+
+DROP VIEW o_unsupported, c_unsupported;
+
+-- M28: Filter canonicalization must be RTE-aware -- rejected, reason:
+-- the filter is on the child side's id column, not on the projected parent
+-- key.  A varattno-only canonicalizer mistakes ch.id for p.id because both
+-- are attno 1 in their own RTEs, and then falsely treats q.id as
+-- row-covered under q.id = 10.
+CREATE TABLE rte_filter_parent (
+    id integer PRIMARY KEY
+);
+CREATE TABLE rte_filter_child (
+    id        integer PRIMARY KEY,
+    parent_id integer NOT NULL UNIQUE REFERENCES rte_filter_parent (id)
+);
+CREATE TABLE rte_filter_review (
+    id        integer PRIMARY KEY,
+    parent_id integer NOT NULL REFERENCES rte_filter_parent (id)
+);
+
+INSERT INTO rte_filter_parent VALUES (1), (10);
+INSERT INTO rte_filter_child VALUES (10, 1);
+INSERT INTO rte_filter_review VALUES (1, 10);
+
+CREATE VIEW rte_filter_review_10 AS
+    SELECT * FROM rte_filter_review WHERE parent_id = 10;
+
+SELECT r.id, q.id
+FROM (
+    SELECT p.id
+    FROM rte_filter_parent p
+    LEFT JOIN rte_filter_child ch FOR KEY (parent_id) -> p (id)
+    WHERE ch.id = 10
+) q
+-- rejected, reason: q is filtered by ch.id, not by q.id
+JOIN rte_filter_review_10 r FOR KEY (parent_id) -> q (id);
+
+DROP VIEW rte_filter_review_10;
+DROP TABLE rte_filter_review, rte_filter_child, rte_filter_parent;
+
+-- M29: Referencing-side filter evidence must not propagate to an
+-- outer-preserved referenced side.  The LEFT/RIGHT subqueries still contain
+-- parent id = 2, so they must not be treated as filtered to id = 1.
+CREATE TABLE outer_filter_root (
+    id integer PRIMARY KEY
+);
+CREATE TABLE outer_filter_parent (
+    id integer PRIMARY KEY REFERENCES outer_filter_root (id)
+);
+CREATE TABLE outer_filter_child (
+    id        integer PRIMARY KEY,
+    parent_id integer NOT NULL REFERENCES outer_filter_parent (id)
+);
+
+INSERT INTO outer_filter_root VALUES (1), (2);
+INSERT INTO outer_filter_parent VALUES (1), (2);
+INSERT INTO outer_filter_child VALUES (10, 1);
+
+CREATE VIEW outer_filter_root_1 AS
+    SELECT * FROM outer_filter_root WHERE id = 1;
+CREATE VIEW outer_filter_child_1 AS
+    SELECT * FROM outer_filter_child WHERE parent_id = 1;
+
+SELECT q.id
+FROM (
+    SELECT p.id
+    FROM outer_filter_parent p
+    LEFT JOIN outer_filter_child_1 c FOR KEY (parent_id) -> p (id)
+) q
+-- rejected, reason: LEFT JOIN preserves unmatched parent rows
+JOIN outer_filter_root_1 r FOR KEY (id) <- q (id);
+
+SELECT q.id
+FROM (
+    SELECT p.id
+    FROM outer_filter_child_1 c
+    RIGHT JOIN outer_filter_parent p FOR KEY (id) <- c (parent_id)
+) q
+-- rejected, reason: RIGHT JOIN preserves unmatched parent rows
+JOIN outer_filter_root_1 r FOR KEY (id) <- q (id);
+
+SELECT q.id
+FROM (
+    SELECT p.id
+    FROM outer_filter_parent p
+    JOIN outer_filter_child_1 c FOR KEY (parent_id) -> p (id)
+) q
+-- accepted, reason: INNER JOIN keeps only matched parent rows
+JOIN outer_filter_root_1 r FOR KEY (id) <- q (id)
+ORDER BY q.id;
+
+DROP VIEW outer_filter_child_1, outer_filter_root_1;
+DROP TABLE outer_filter_child, outer_filter_parent, outer_filter_root;
+
+-- M30: Sublinks are not safe matched-filter conjuncts -- rejected, reason:
+-- matching key = (SELECT ...) predicates must not be retained as
+-- direct equality filter evidence.
+CREATE VIEW c_sublink AS
+    SELECT * FROM customers WHERE id = (SELECT 100);
+CREATE VIEW o_sublink AS
+    SELECT * FROM orders WHERE customer_id = (SELECT 100);
+
+SELECT o.id, c.name
+FROM o_sublink o
+-- rejected, reason: matched filters contain sublinks
+JOIN c_sublink c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+
+DROP VIEW o_sublink, c_sublink;
+
+-- M31: Cross-type FK key joins are rejected by key-join proof.  PostgreSQL
+-- accepts the FK, but key join does not compute proof facts from it.
+-- Supporting this would also require matched-filter proof to compare
+-- filter evidence across different type-specific equality operators.
+CREATE TABLE cross_type_customers (
+    tenant_id smallint NOT NULL,
+    id        integer  NOT NULL,
+    name      text     NOT NULL,
+    PRIMARY KEY (tenant_id, id)
+);
+CREATE TABLE cross_type_orders (
+    tenant_id   bigint  NOT NULL,
+    id          integer NOT NULL,
+    customer_id integer NOT NULL,
+    PRIMARY KEY (tenant_id, id),
+    FOREIGN KEY (tenant_id, customer_id)
+        REFERENCES cross_type_customers (tenant_id, id)
+);
+
+INSERT INTO cross_type_customers VALUES (1, 1, 'Alice'), (2, 1, 'Carol');
+INSERT INTO cross_type_orders VALUES (1, 10, 1), (2, 20, 1);
+
+SELECT o.id, c.name
+FROM cross_type_orders o
+-- rejected, reason: FK and PK key types differ
+JOIN cross_type_customers c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id)
+ORDER BY o.id;
+
+CREATE VIEW cross_type_customers_1 AS
+    SELECT * FROM cross_type_customers WHERE tenant_id = 1;
+CREATE VIEW cross_type_orders_1 AS
+    SELECT * FROM cross_type_orders WHERE tenant_id = 1;
+
+SELECT o.id, c.name
+FROM cross_type_orders_1 o
+-- rejected, reason: FK and PK key types differ
+JOIN cross_type_customers_1 c FOR KEY (tenant_id, id) <- o (tenant_id, customer_id);
+
+DROP VIEW cross_type_orders_1, cross_type_customers_1;
+DROP TABLE cross_type_orders, cross_type_customers;
+
+-- Cleanup
+RESET mf.tenant;
+DROP VIEW tenant_orders, tenant_customers;
+DROP TABLE orders, customers;
+DROP SCHEMA mf;
+RESET search_path;
diff --git a/src/test/regress/sql/key_join_mcdc.sql b/src/test/regress/sql/key_join_mcdc.sql
new file mode 100644
index 00000000000..c974b199d86
--- /dev/null
+++ b/src/test/regress/sql/key_join_mcdc.sql
@@ -0,0 +1,758 @@
+--
+-- key_join_mcdc
+--
+-- Focused local MC/DC coverage for build_key_join_quals(), its callers, and
+-- the equality-operator usability pre-guard.
+--
+
+\set VERBOSITY terse
+
+CREATE SCHEMA key_join_mcdc;
+SET search_path = key_join_mcdc, public;
+
+CREATE TABLE mcdc_parent
+(
+    id        int PRIMARY KEY,
+    tenant_id int NOT NULL,
+    code      int NOT NULL,
+    UNIQUE (tenant_id, code)
+);
+
+CREATE TABLE mcdc_child
+(
+    id        int PRIMARY KEY,
+    parent_id int UNIQUE NOT NULL REFERENCES mcdc_parent (id),
+    tenant_id int NOT NULL,
+    code      int NOT NULL,
+    UNIQUE (tenant_id, code),
+    FOREIGN KEY (tenant_id, code) REFERENCES mcdc_parent (tenant_id, code)
+);
+
+CREATE TABLE mcdc_reader
+(
+    id        int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES mcdc_parent (id),
+    tenant_id int NOT NULL,
+    code      int NOT NULL,
+    FOREIGN KEY (tenant_id, code) REFERENCES mcdc_parent (tenant_id, code)
+);
+
+CREATE TABLE mcdc_noinherit_child
+(
+    id        int PRIMARY KEY,
+    parent_id int,
+    NOT NULL parent_id NO INHERIT,
+    FOREIGN KEY (parent_id) REFERENCES mcdc_parent (id)
+);
+
+CREATE TABLE mcdc_self_ref
+(
+    id int PRIMARY KEY REFERENCES mcdc_self_ref (id)
+);
+
+INSERT INTO mcdc_parent VALUES (1, 10, 100), (2, 20, 200);
+INSERT INTO mcdc_child VALUES (11, 1, 10, 100), (22, 2, 20, 200);
+INSERT INTO mcdc_reader VALUES (101, 1, 10, 100), (202, 2, 20, 200);
+INSERT INTO mcdc_noinherit_child VALUES (111, 1), (222, 2);
+INSERT INTO mcdc_self_ref VALUES (1), (2);
+
+CREATE FUNCTION mcdc_positive_int(int) RETURNS boolean
+LANGUAGE sql IMMUTABLE STRICT
+AS $$ SELECT $1 > 0 $$;
+
+CREATE OPERATOR @+ (
+    RIGHTARG = int,
+    FUNCTION = mcdc_positive_int
+);
+
+-- Expression indexes split table-column self dependencies from function deps.
+CREATE INDEX mcdc_parent_positive_idx
+    ON mcdc_parent ((mcdc_positive_int(id)));
+DROP INDEX mcdc_parent_positive_idx;
+CREATE INDEX mcdc_parent_positive_partial_idx
+    ON mcdc_parent (id) WHERE mcdc_positive_int(id);
+DROP INDEX mcdc_parent_positive_partial_idx;
+CREATE TABLE mcdc_partition_expr_parent
+(
+    id int NOT NULL
+) PARTITION BY RANGE ((id + 0));
+DROP TABLE mcdc_partition_expr_parent;
+
+CREATE FUNCTION mcdc_stable_int_eq(a int, b int) RETURNS boolean
+LANGUAGE sql STABLE STRICT
+AS $$ SELECT a = b $$;
+
+CREATE OPERATOR === (
+    LEFTARG = int,
+    RIGHTARG = int,
+    PROCEDURE = mcdc_stable_int_eq
+);
+
+CREATE FUNCTION mcdc_hash_bridge_eq(a int, b bigint) RETURNS boolean
+LANGUAGE sql IMMUTABLE STRICT
+AS $$ SELECT a = b::int $$;
+
+CREATE OPERATOR =## (
+    LEFTARG = int,
+    RIGHTARG = bigint,
+    PROCEDURE = mcdc_hash_bridge_eq
+);
+
+CREATE OPERATOR FAMILY mcdc_btree_bridge_ops USING btree;
+
+ALTER OPERATOR FAMILY mcdc_btree_bridge_ops USING btree ADD
+    OPERATOR 3 =## (int, bigint);
+
+CREATE OPERATOR FAMILY mcdc_hash_bridge_ops USING hash;
+
+CREATE OPERATOR CLASS mcdc_hash_bridge_int_ops
+FOR TYPE int USING hash FAMILY mcdc_hash_bridge_ops AS
+    OPERATOR 1 = (int, int),
+    FUNCTION 1 hashint4(int);
+
+ALTER OPERATOR FAMILY mcdc_hash_bridge_ops USING hash ADD
+    OPERATOR 1 =## (int, bigint),
+    FUNCTION 1 hashint8(bigint);
+
+-- The nondeterministic-collation GROUP BY case is exercised in key_join_icu.
+
+CREATE TABLE mcdc_text_cast_parent
+(
+    id text PRIMARY KEY
+);
+
+CREATE TABLE mcdc_text_cast_reader
+(
+    id        int PRIMARY KEY,
+    parent_id text NOT NULL REFERENCES mcdc_text_cast_parent (id)
+);
+
+CREATE TABLE mcdc_regclass_parent
+(
+    id regclass PRIMARY KEY
+);
+
+CREATE TABLE mcdc_regclass_reader
+(
+    id        int PRIMARY KEY,
+    parent_id regclass NOT NULL REFERENCES mcdc_regclass_parent (id)
+);
+
+CREATE DOMAIN mcdc_num_dom AS numeric(4,0);
+
+CREATE TABLE mcdc_numdom_parent
+(
+    id mcdc_num_dom PRIMARY KEY
+);
+
+CREATE TABLE mcdc_numdom_reader
+(
+    id        int PRIMARY KEY,
+    parent_id mcdc_num_dom NOT NULL REFERENCES mcdc_numdom_parent (id)
+);
+
+CREATE DOMAIN mcdc_sig_dom AS int;
+
+CREATE FUNCTION mcdc_sig_dom_cmp(a mcdc_sig_dom, b mcdc_sig_dom)
+RETURNS int LANGUAGE sql IMMUTABLE STRICT
+AS $$ SELECT btint4cmp(a::int, b::int) $$;
+
+CREATE FUNCTION mcdc_sig_dom_eq(a mcdc_sig_dom, b mcdc_sig_dom)
+RETURNS boolean LANGUAGE sql IMMUTABLE STRICT
+AS $$ SELECT a::int = b::int $$;
+
+CREATE OPERATOR === (
+    LEFTARG = mcdc_sig_dom,
+    RIGHTARG = mcdc_sig_dom,
+    PROCEDURE = mcdc_sig_dom_eq,
+    COMMUTATOR = ===,
+    RESTRICT = eqsel,
+    JOIN = eqjoinsel,
+    MERGES
+);
+
+CREATE OPERATOR CLASS mcdc_sig_dom_ops
+FOR TYPE mcdc_sig_dom USING btree AS
+    OPERATOR 3 === (mcdc_sig_dom, mcdc_sig_dom),
+    FUNCTION 1 mcdc_sig_dom_cmp(mcdc_sig_dom, mcdc_sig_dom);
+
+CREATE TABLE mcdc_sig_parent
+(
+    id mcdc_sig_dom
+);
+
+CREATE UNIQUE INDEX mcdc_sig_parent_idx
+    ON mcdc_sig_parent USING btree (id mcdc_sig_dom_ops);
+
+CREATE TABLE mcdc_sig_child
+(
+    id        int PRIMARY KEY,
+    parent_id mcdc_sig_dom NOT NULL REFERENCES mcdc_sig_parent (id)
+);
+
+INSERT INTO mcdc_text_cast_parent VALUES ('a'), ('b');
+INSERT INTO mcdc_text_cast_reader VALUES (401, 'a'), (402, 'b');
+INSERT INTO mcdc_regclass_parent VALUES ('pg_class'), ('pg_type');
+INSERT INTO mcdc_regclass_reader VALUES (451, 'pg_class'), (452, 'pg_type');
+INSERT INTO mcdc_numdom_parent VALUES (1), (2);
+INSERT INTO mcdc_numdom_reader VALUES (501, 1), (502, 2);
+INSERT INTO mcdc_sig_parent VALUES (1), (2);
+INSERT INTO mcdc_sig_child VALUES (601, 1), (602, 2);
+
+CREATE FUNCTION mcdc_varchar_eq(a varchar, b varchar) RETURNS boolean
+LANGUAGE sql IMMUTABLE STRICT
+AS $$ SELECT a::text = b::text $$;
+
+CREATE OPERATOR === (
+    LEFTARG = varchar,
+    RIGHTARG = varchar,
+    PROCEDURE = mcdc_varchar_eq
+);
+
+CREATE FUNCTION mcdc_varchar_cmp(a varchar, b varchar) RETURNS int
+LANGUAGE sql IMMUTABLE STRICT
+AS $$ SELECT bttextcmp(a::text, b::text) $$;
+
+CREATE OPERATOR CLASS mcdc_varchar_ops
+FOR TYPE varchar USING btree AS
+    OPERATOR 3 === (varchar, varchar),
+    FUNCTION 1 mcdc_varchar_cmp(varchar, varchar);
+
+CREATE TABLE mcdc_varchar_parent
+(
+    id varchar(4) NOT NULL
+);
+
+CREATE UNIQUE INDEX mcdc_varchar_parent_custom_idx
+    ON mcdc_varchar_parent USING btree (id mcdc_varchar_ops);
+
+ALTER TABLE mcdc_varchar_parent ADD PRIMARY KEY (id);
+
+CREATE TABLE mcdc_varchar_child
+(
+    id        int PRIMARY KEY,
+    parent_id varchar(4) NOT NULL REFERENCES mcdc_varchar_parent (id)
+);
+
+CREATE DOMAIN mcdc_varchar_sig_dom AS varchar(4);
+
+CREATE FUNCTION mcdc_varchar_sig_dom_cmp(a mcdc_varchar_sig_dom,
+                                         b mcdc_varchar_sig_dom)
+RETURNS int LANGUAGE sql IMMUTABLE STRICT
+AS $$ SELECT bttextcmp(a::text, b::text) $$;
+
+CREATE FUNCTION mcdc_varchar_sig_dom_eq(a mcdc_varchar_sig_dom,
+                                        b mcdc_varchar_sig_dom)
+RETURNS boolean LANGUAGE sql IMMUTABLE STRICT
+AS $$ SELECT a::text = b::text $$;
+
+CREATE OPERATOR === (
+    LEFTARG = mcdc_varchar_sig_dom,
+    RIGHTARG = mcdc_varchar_sig_dom,
+    PROCEDURE = mcdc_varchar_sig_dom_eq,
+    COMMUTATOR = ===,
+    RESTRICT = eqsel,
+    JOIN = eqjoinsel,
+    MERGES
+);
+
+CREATE OPERATOR CLASS mcdc_varchar_sig_dom_ops
+FOR TYPE mcdc_varchar_sig_dom USING btree AS
+    OPERATOR 3 === (mcdc_varchar_sig_dom, mcdc_varchar_sig_dom),
+    FUNCTION 1 mcdc_varchar_sig_dom_cmp(mcdc_varchar_sig_dom,
+                                        mcdc_varchar_sig_dom);
+
+CREATE TABLE mcdc_varchar_sig_parent
+(
+    id mcdc_varchar_sig_dom
+);
+
+CREATE UNIQUE INDEX mcdc_varchar_sig_parent_idx
+    ON mcdc_varchar_sig_parent USING btree (id mcdc_varchar_sig_dom_ops);
+
+CREATE TABLE mcdc_varchar_sig_child
+(
+    id        int PRIMARY KEY,
+    parent_id mcdc_varchar_sig_dom NOT NULL
+        REFERENCES mcdc_varchar_sig_parent (id)
+);
+
+INSERT INTO mcdc_varchar_parent VALUES ('aa'), ('bb');
+INSERT INTO mcdc_varchar_child VALUES (901, 'aa'), (902, 'bb');
+INSERT INTO mcdc_varchar_sig_parent VALUES ('aa'), ('bb');
+INSERT INTO mcdc_varchar_sig_child VALUES (911, 'aa'), (912, 'bb');
+
+CREATE VIEW mcdc_text_cast_filter_v AS
+SELECT *
+FROM mcdc_text_cast_parent
+WHERE id::varchar === 'a'::varchar;
+
+CREATE VIEW mcdc_varchar_typmod_filter_v AS
+SELECT *
+FROM mcdc_varchar_parent
+WHERE id::varchar === 'aa'::varchar;
+
+CREATE VIEW mcdc_numdom_typmod_filter_v AS
+SELECT *
+FROM mcdc_numdom_parent
+WHERE id::numeric = 1;
+
+-- Live parse path: single-column and multi-column key quals.
+SELECT p.id, c.id
+FROM mcdc_parent p
+JOIN mcdc_child c FOR KEY (parent_id) -> p (id)
+ORDER BY p.id;
+
+SELECT p.tenant_id, p.code, c.id
+FROM mcdc_parent p
+JOIN mcdc_child c FOR KEY (tenant_id, code) -> p (tenant_id, code)
+ORDER BY p.tenant_id, p.code;
+
+SELECT p.id, c.id
+FROM mcdc_parent p
+JOIN mcdc_noinherit_child c FOR KEY (parent_id) -> p (id)
+ORDER BY p.id;
+
+-- Self-join elimination must update the key-join arrow alias RT index.
+SET enable_self_join_elimination = on;
+SELECT s2.id
+FROM mcdc_self_ref s1
+JOIN mcdc_self_ref s2 FOR KEY (id) -> s1 (id)
+ORDER BY s2.id;
+RESET enable_self_join_elimination;
+
+-- Plain non-inner no-qual join path: no projected key facts.
+SELECT r.id, j.id
+FROM (
+    SELECT p.id
+    FROM mcdc_parent p
+    NATURAL LEFT JOIN (SELECT 1 AS extra) s
+) j
+JOIN mcdc_reader r FOR KEY (parent_id) -> j (id)
+ORDER BY r.id;
+
+-- Function ordinality path: no projected key facts.
+SELECT r.id, j.id
+FROM (
+    SELECT p.id
+    FROM mcdc_parent p
+    CROSS JOIN LATERAL generate_series(1, 1) WITH ORDINALITY AS g(x, n)
+) j
+JOIN mcdc_reader r FOR KEY (parent_id) -> j (id)
+ORDER BY r.id;
+
+-- Duplicate inactive unique facts preserve the first diagnostic source.
+CREATE TABLE mcdc_dupe_parent
+(
+    id int PRIMARY KEY
+);
+
+CREATE TABLE mcdc_dupe_reader
+(
+    id        int PRIMARY KEY,
+    parent_id int NOT NULL REFERENCES mcdc_dupe_parent (id)
+);
+
+CREATE TABLE mcdc_dupe_nullable_reader
+(
+    id        int PRIMARY KEY,
+    parent_id int REFERENCES mcdc_dupe_parent (id)
+);
+
+INSERT INTO mcdc_dupe_parent VALUES (1), (2);
+INSERT INTO mcdc_dupe_reader VALUES (701, 1), (702, 1), (703, 2);
+INSERT INTO mcdc_dupe_nullable_reader VALUES (801, 1), (802, NULL);
+
+CREATE VIEW mcdc_dupe_active_v AS
+SELECT DISTINCT p.id
+FROM mcdc_dupe_parent p;
+
+CREATE VIEW mcdc_dupe_inactive_nn_v AS
+SELECT r.parent_id
+FROM mcdc_dupe_parent p
+LEFT JOIN mcdc_dupe_reader r FOR KEY (parent_id) -> p (id);
+
+CREATE VIEW mcdc_dupe_v AS
+SELECT q.id
+FROM (SELECT DISTINCT p.id FROM mcdc_dupe_parent p) q
+LEFT JOIN mcdc_dupe_reader r FOR KEY (parent_id) -> q (id);
+
+SELECT r.id, v.id
+FROM mcdc_dupe_v v
+JOIN mcdc_dupe_reader r FOR KEY (parent_id) -> v (id)
+ORDER BY r.id, v.id;
+
+SELECT n.id, v.id
+FROM mcdc_dupe_active_v v
+JOIN mcdc_dupe_nullable_reader n FOR KEY (parent_id) -> v (id)
+ORDER BY n.id, v.id;
+
+SELECT rv.parent_id, v.id
+FROM mcdc_dupe_inactive_nn_v rv
+JOIN mcdc_dupe_active_v v FOR KEY (id) <- rv (parent_id)
+ORDER BY rv.parent_id, v.id;
+
+-- Stored revalidation path: no FILTER, join-local FILTERs, and multi-column.
+CREATE VIEW mcdc_plain_v AS
+SELECT p.id
+FROM mcdc_parent p
+LEFT JOIN mcdc_child c FOR KEY (parent_id) -> p (id);
+
+CREATE VIEW mcdc_filter_v AS
+SELECT p.id
+FROM mcdc_parent p
+LEFT JOIN mcdc_child c FOR KEY (parent_id) -> p (id)
+FILTER (WHERE c.id > 0);
+
+CREATE VIEW mcdc_unary_filter_v AS
+SELECT p.id
+FROM mcdc_parent p
+LEFT JOIN mcdc_child c FOR KEY (parent_id) -> p (id)
+FILTER (WHERE @+ c.id);
+
+CREATE VIEW mcdc_other_side_filter_v AS
+SELECT p.id
+FROM mcdc_parent p
+LEFT JOIN mcdc_child c FOR KEY (parent_id) -> p (id)
+FILTER (WHERE p.id = 1);
+
+CREATE VIEW mcdc_duplicate_filter_parent_v AS
+SELECT *
+FROM mcdc_parent
+WHERE id = 1 AND id = 1;
+
+CREATE VIEW mcdc_duplicate_filter_reader_v AS
+SELECT *
+FROM mcdc_reader
+WHERE parent_id = 1;
+
+CREATE VIEW mcdc_group_inactive_rowcov_v AS
+SELECT p.id, p.tenant_id, p.code
+FROM mcdc_parent p
+GROUP BY p.id;
+
+CREATE VIEW mcdc_reader_parent_1_v AS
+SELECT *
+FROM mcdc_reader
+WHERE parent_id = 1;
+
+CREATE VIEW mcdc_group_inactive_rowcov_join_v AS
+SELECT r.parent_id
+FROM mcdc_group_inactive_rowcov_v v
+JOIN mcdc_reader_parent_1_v r FOR KEY (parent_id) -> v (id);
+
+CREATE VIEW mcdc_distinct_filter_v AS
+SELECT p.id
+FROM mcdc_parent p
+LEFT JOIN mcdc_child c FOR KEY (parent_id) -> p (id)
+FILTER (WHERE c.id = CASE WHEN 1 IS DISTINCT FROM 2 THEN 11 ELSE 0 END);
+
+CREATE VIEW mcdc_array_filter_v AS
+SELECT p.id
+FROM mcdc_parent p
+LEFT JOIN mcdc_child c FOR KEY (parent_id) -> p (id)
+FILTER (WHERE c.id = CASE WHEN 11 = ANY (ARRAY[11, 22]) THEN 11 ELSE 0 END);
+
+CREATE VIEW mcdc_expr_filter_v AS
+SELECT *
+FROM mcdc_parent
+WHERE id + 0 = 1;
+
+CREATE VIEW mcdc_relabel_expr_filter_v AS
+SELECT *
+FROM mcdc_parent
+WHERE (id::text)::varchar = '1';
+
+CREATE VIEW mcdc_stable_filter_v AS
+SELECT *
+FROM mcdc_parent
+WHERE id === 1;
+
+CREATE VIEW mcdc_hash_filter_parent_v AS
+SELECT *
+FROM mcdc_parent
+WHERE id =## 1::bigint;
+
+CREATE VIEW mcdc_hash_filter_reader_v AS
+SELECT *
+FROM mcdc_reader
+WHERE parent_id =## 1::bigint;
+
+CREATE VIEW mcdc_multi_v AS
+SELECT p.tenant_id, p.code
+FROM mcdc_parent p
+LEFT JOIN mcdc_child c FOR KEY (tenant_id, code) -> p (tenant_id, code);
+
+CREATE VIEW mcdc_second_cte_v AS
+WITH unused AS (SELECT id FROM mcdc_parent WHERE false),
+     parents AS (SELECT id FROM mcdc_parent)
+SELECT id FROM parents;
+
+CREATE VIEW mcdc_jointree_search_v AS
+SELECT q.id
+FROM (SELECT count(*) AS n FROM mcdc_parent) one,
+     (
+        (mcdc_parent p
+         LEFT JOIN mcdc_child c FOR KEY (parent_id) -> p (id))
+            AS q(id, tenant_id, code, child_id, child_parent_id,
+                 child_tenant_id, child_code)
+        JOIN mcdc_reader r FOR KEY (parent_id) -> q (id)
+     );
+
+CREATE VIEW mcdc_stored_plain_join_search_v AS
+SELECT v.id, c.id AS child_id
+FROM (mcdc_plain_v v
+      JOIN mcdc_reader r FOR KEY (parent_id) -> v (id))
+JOIN mcdc_child c ON c.parent_id = v.id;
+
+CREATE TABLE mcdc_rule_search_log (id int);
+
+CREATE RULE mcdc_rule_search_a AS ON DELETE TO mcdc_rule_search_log
+DO ALSO SELECT v.id, r.id AS reader_id
+FROM mcdc_plain_v v
+JOIN mcdc_reader r FOR KEY (parent_id) -> v (id);
+
+CREATE RULE mcdc_rule_search_b AS ON UPDATE TO mcdc_rule_search_log
+DO ALSO SELECT v.id, r.id AS reader_id
+FROM mcdc_plain_v v
+JOIN mcdc_reader r FOR KEY (parent_id) -> v (id);
+
+CREATE FUNCTION mcdc_rule_search_func() RETURNS TABLE(id int)
+LANGUAGE sql
+BEGIN ATOMIC
+    SELECT v.id
+    FROM mcdc_plain_v v
+    JOIN mcdc_reader r FOR KEY (parent_id) -> v (id);
+END;
+
+CREATE OR REPLACE VIEW mcdc_plain_v AS
+SELECT p.id
+FROM mcdc_parent p
+LEFT JOIN mcdc_child c FOR KEY (parent_id) -> p (id);
+
+DROP FUNCTION mcdc_rule_search_func();
+DROP RULE mcdc_rule_search_a ON mcdc_rule_search_log;
+DROP RULE mcdc_rule_search_b ON mcdc_rule_search_log;
+DROP TABLE mcdc_rule_search_log;
+
+SELECT r.id, v.id
+FROM mcdc_plain_v v
+JOIN mcdc_reader r FOR KEY (parent_id) -> v (id)
+ORDER BY r.id;
+
+SELECT r.id, v.id
+FROM mcdc_filter_v v
+JOIN mcdc_reader r FOR KEY (parent_id) -> v (id)
+ORDER BY r.id;
+
+SELECT r.id, v.id
+FROM mcdc_unary_filter_v v
+JOIN mcdc_reader r FOR KEY (parent_id) -> v (id)
+ORDER BY r.id;
+
+SELECT r.id, v.id
+FROM mcdc_other_side_filter_v v
+JOIN mcdc_reader r FOR KEY (parent_id) -> v (id)
+ORDER BY r.id;
+
+SELECT r.id, v.id
+FROM mcdc_duplicate_filter_parent_v v
+JOIN mcdc_duplicate_filter_reader_v r FOR KEY (parent_id) -> v (id)
+ORDER BY r.id;
+
+-- Filter propagation skips inactive row-coverage targets.
+SELECT q.parent_id, p.id
+FROM mcdc_group_inactive_rowcov_join_v q
+JOIN mcdc_parent p FOR KEY (id) <- q (parent_id)
+ORDER BY q.parent_id;
+
+SELECT r.id, v.id
+FROM mcdc_distinct_filter_v v
+JOIN mcdc_reader r FOR KEY (parent_id) -> v (id)
+ORDER BY r.id;
+
+SELECT r.id, v.id
+FROM mcdc_array_filter_v v
+JOIN mcdc_reader r FOR KEY (parent_id) -> v (id)
+ORDER BY r.id;
+
+SELECT r.id, v.id
+FROM mcdc_expr_filter_v v
+JOIN mcdc_reader r FOR KEY (parent_id) -> v (id)
+ORDER BY r.id;
+
+SELECT r.id, v.id
+FROM mcdc_relabel_expr_filter_v v
+JOIN mcdc_reader r FOR KEY (parent_id) -> v (id)
+ORDER BY r.id;
+
+SELECT r.id, v.id
+FROM mcdc_stable_filter_v v
+JOIN mcdc_reader r FOR KEY (parent_id) -> v (id)
+ORDER BY r.id;
+
+SELECT r.id, v.id
+FROM mcdc_hash_filter_parent_v v
+JOIN mcdc_hash_filter_reader_v r FOR KEY (parent_id) -> v (id)
+ORDER BY r.id;
+
+SELECT r.id, v.tenant_id, v.code
+FROM mcdc_multi_v v
+JOIN mcdc_reader r FOR KEY (tenant_id, code) -> v (tenant_id, code)
+ORDER BY r.id;
+
+SELECT r.id, v.id
+FROM mcdc_second_cte_v v
+JOIN mcdc_reader r FOR KEY (parent_id) -> v (id)
+ORDER BY r.id;
+
+SELECT r.id, v.id
+FROM mcdc_jointree_search_v v
+JOIN mcdc_reader r FOR KEY (parent_id) -> v (id)
+ORDER BY r.id;
+
+-- CTE projection path: skip an outer Var while projecting current key facts.
+SELECT p.id, probe.ok
+FROM mcdc_parent p,
+LATERAL (
+    WITH outer_cols AS (
+        SELECT p.id AS outer_id, c.parent_id
+        FROM mcdc_child c
+    )
+    SELECT true AS ok
+    FROM outer_cols oc
+    JOIN mcdc_parent pp FOR KEY (id) <- oc (parent_id)
+    WHERE oc.outer_id = p.id AND pp.id = p.id
+) probe
+ORDER BY p.id;
+
+-- Query projection path: system columns are not projected key facts.
+SELECT r.id, q.id
+FROM (
+    SELECT p.id, p.ctid
+    FROM mcdc_parent p
+) q
+JOIN mcdc_reader r FOR KEY (parent_id) -> q (id)
+ORDER BY r.id;
+
+-- GROUP BY expression path: expression keys do not prove projected facts.
+SELECT r.id, g.id
+FROM (
+    SELECT p.id + 0 AS id
+    FROM mcdc_parent p
+    GROUP BY p.id + 0
+) g
+JOIN mcdc_reader r FOR KEY (parent_id) -> g (id)
+ORDER BY r.id;
+
+-- GROUP BY binary-compatible equality identity path: no projected key facts.
+SELECT rr.id, g.id
+FROM (
+    SELECT p.id
+    FROM mcdc_regclass_parent p
+    GROUP BY p.id
+) g
+JOIN mcdc_regclass_reader rr FOR KEY (parent_id) -> g (id)
+ORDER BY rr.id;
+
+-- Non-domain relabel filters are not canonical key filters.
+SELECT vr.id, v.id
+FROM mcdc_text_cast_filter_v v
+JOIN mcdc_text_cast_reader vr FOR KEY (parent_id) -> v (id)
+ORDER BY vr.id;
+
+-- Varchar typmod relabel filters are not canonical key filters.
+SELECT vc.id, v.id
+FROM mcdc_varchar_typmod_filter_v v
+JOIN mcdc_varchar_child vc FOR KEY (parent_id) -> v (id)
+ORDER BY vc.id;
+
+-- Domain relabel filters must keep the normalized base typmod.
+SELECT nr.id, v.id
+FROM mcdc_numdom_typmod_filter_v v
+JOIN mcdc_numdom_reader nr FOR KEY (parent_id) -> v (id)
+ORDER BY nr.id;
+
+-- Varchar opclasses can use either varchar or text equality input.
+SELECT c.id, g.id
+FROM (
+    SELECT p.id
+    FROM mcdc_varchar_parent p
+    GROUP BY p.id
+) g
+JOIN mcdc_varchar_child c FOR KEY (parent_id) -> g (id)
+ORDER BY c.id;
+
+-- Same-domain FK operators must use the normalized base equality type.
+SELECT c.id, p.id
+FROM mcdc_sig_parent p
+JOIN mcdc_sig_child c FOR KEY (parent_id) -> p (id)
+ORDER BY c.id;
+
+-- Varchar-domain FK operators must use varchar or text equality input.
+SELECT c.id, p.id
+FROM mcdc_varchar_sig_parent p
+JOIN mcdc_varchar_sig_child c FOR KEY (parent_id) -> p (id)
+ORDER BY c.id;
+
+-- Procedure dependents without stored SQL bodies have nothing to revalidate.
+CREATE FUNCTION mcdc_support_func(internal)
+RETURNS internal
+LANGUAGE internal STRICT
+AS 'textlike_support';
+
+CREATE FUNCTION mcdc_support_subject(int) RETURNS int
+LANGUAGE sql STABLE SUPPORT mcdc_support_func
+AS $$ SELECT $1 $$;
+
+ALTER FUNCTION mcdc_support_func(internal) STABLE;
+
+DROP FUNCTION mcdc_support_subject(int);
+DROP FUNCTION mcdc_support_func(internal);
+DROP VIEW mcdc_duplicate_filter_reader_v;
+DROP VIEW mcdc_duplicate_filter_parent_v;
+DROP VIEW mcdc_group_inactive_rowcov_join_v;
+DROP VIEW mcdc_reader_parent_1_v;
+DROP VIEW mcdc_group_inactive_rowcov_v;
+DROP VIEW mcdc_stable_filter_v;
+DROP OPERATOR === (int, int);
+DROP FUNCTION mcdc_stable_int_eq(int, int);
+DROP VIEW mcdc_hash_filter_reader_v;
+DROP VIEW mcdc_hash_filter_parent_v;
+DROP OPERATOR CLASS mcdc_hash_bridge_int_ops USING hash;
+DROP OPERATOR FAMILY mcdc_hash_bridge_ops USING hash;
+DROP OPERATOR FAMILY mcdc_btree_bridge_ops USING btree;
+DROP OPERATOR =## (int, bigint);
+DROP FUNCTION mcdc_hash_bridge_eq(int, bigint);
+
+DROP VIEW mcdc_text_cast_filter_v;
+DROP TABLE mcdc_text_cast_reader, mcdc_text_cast_parent;
+DROP TABLE mcdc_regclass_reader, mcdc_regclass_parent;
+DROP VIEW mcdc_varchar_typmod_filter_v;
+DROP TABLE mcdc_varchar_child, mcdc_varchar_parent;
+DROP OPERATOR CLASS mcdc_varchar_ops USING btree;
+DROP OPERATOR FAMILY mcdc_varchar_ops USING btree;
+DROP FUNCTION mcdc_varchar_cmp(varchar, varchar);
+DROP OPERATOR === (varchar, varchar);
+DROP FUNCTION mcdc_varchar_eq(varchar, varchar);
+DROP TABLE mcdc_varchar_sig_child, mcdc_varchar_sig_parent;
+DROP OPERATOR CLASS mcdc_varchar_sig_dom_ops USING btree;
+DROP OPERATOR FAMILY mcdc_varchar_sig_dom_ops USING btree;
+DROP OPERATOR === (mcdc_varchar_sig_dom, mcdc_varchar_sig_dom);
+DROP FUNCTION mcdc_varchar_sig_dom_cmp(mcdc_varchar_sig_dom,
+                                       mcdc_varchar_sig_dom);
+DROP FUNCTION mcdc_varchar_sig_dom_eq(mcdc_varchar_sig_dom,
+                                      mcdc_varchar_sig_dom);
+DROP DOMAIN mcdc_varchar_sig_dom;
+DROP VIEW mcdc_numdom_typmod_filter_v;
+DROP TABLE mcdc_numdom_reader, mcdc_numdom_parent;
+DROP DOMAIN mcdc_num_dom;
+DROP TABLE mcdc_sig_child, mcdc_sig_parent;
+DROP OPERATOR CLASS mcdc_sig_dom_ops USING btree;
+DROP OPERATOR FAMILY mcdc_sig_dom_ops USING btree;
+DROP OPERATOR === (mcdc_sig_dom, mcdc_sig_dom);
+DROP FUNCTION mcdc_sig_dom_cmp(mcdc_sig_dom, mcdc_sig_dom);
+DROP FUNCTION mcdc_sig_dom_eq(mcdc_sig_dom, mcdc_sig_dom);
+DROP DOMAIN mcdc_sig_dom;
+
+DROP SCHEMA key_join_mcdc CASCADE;
diff --git a/src/test/regress/sql/key_join_overview.sql b/src/test/regress/sql/key_join_overview.sql
new file mode 100644
index 00000000000..0c737119749
--- /dev/null
+++ b/src/test/regress/sql/key_join_overview.sql
@@ -0,0 +1,764 @@
+--
+-- key_join_overview
+--
+-- One clear example per category, organized by SQL feature.
+-- Shared fixture at top, all queries in the middle, all DROPs at bottom.
+--
+
+-- Referenced tables (PK side)
+CREATE TABLE ref_products (
+    prod_id   INTEGER PRIMARY KEY,
+    prod_name TEXT NOT NULL
+);
+
+CREATE TABLE ref_locations (
+    loc_country CHAR(2)     NOT NULL,
+    loc_zip     TEXT        NOT NULL,
+    PRIMARY KEY (loc_country, loc_zip)
+);
+
+CREATE TABLE ref_departments (
+    dept_id   INTEGER PRIMARY KEY,
+    dept_name TEXT NOT NULL
+);
+
+CREATE TABLE ref_teams (
+    team_id      INTEGER PRIMARY KEY,
+    team_dept_id INTEGER NOT NULL REFERENCES ref_departments(dept_id)
+);
+
+-- Referencing tables (FK side)
+CREATE TABLE fk_orders (
+    ord_id      INTEGER PRIMARY KEY,
+    ord_prod_id INTEGER NOT NULL REFERENCES ref_products(prod_id)
+);
+
+CREATE TABLE fk_reviews (
+    rev_id      INTEGER PRIMARY KEY,
+    rev_prod_id INTEGER NOT NULL REFERENCES ref_products(prod_id)
+);
+
+CREATE TABLE fk_warehouses (
+    wh_id      INTEGER PRIMARY KEY,
+    wh_country CHAR(2)     NOT NULL,
+    wh_zip     TEXT        NOT NULL,
+    FOREIGN KEY (wh_country, wh_zip) REFERENCES ref_locations(loc_country, loc_zip)
+);
+
+CREATE TABLE fk_employees (
+    emp_id      INTEGER PRIMARY KEY,
+    emp_team_id INTEGER NOT NULL REFERENCES ref_teams(team_id)
+);
+
+-- Special-purpose tables
+CREATE TABLE fk_returns (
+    ret_id      INTEGER PRIMARY KEY,
+    ret_prod_id INTEGER UNIQUE REFERENCES ref_products(prod_id)  -- nullable, unique
+);
+
+CREATE TABLE fk_inventory (
+    inv_id      INTEGER PRIMARY KEY,
+    inv_prod_id INTEGER NOT NULL UNIQUE REFERENCES ref_products(prod_id)
+);
+
+CREATE TABLE fk_deferrable_unique_inventory (
+    dui_id      INTEGER PRIMARY KEY,
+    dui_prod_id INTEGER NOT NULL REFERENCES ref_products(prod_id),
+    CONSTRAINT fk_deferrable_unique_inventory_uniq
+        UNIQUE (dui_prod_id) DEFERRABLE INITIALLY IMMEDIATE
+);
+
+-- For NOT VALID test
+CREATE TABLE fk_notvalid_child (
+    nv_id    INTEGER PRIMARY KEY,
+    nv_p_id  INTEGER
+);
+ALTER TABLE fk_notvalid_child
+    ADD CONSTRAINT fk_notvalid_child_fk
+    FOREIGN KEY (nv_p_id) REFERENCES ref_products(prod_id) NOT VALID;
+
+CREATE TABLE fk_notvalid_notnull_child (
+    nvn_id   INTEGER PRIMARY KEY,
+    nvn_p_id INTEGER REFERENCES ref_products(prod_id)
+);
+
+-- For NOT ENFORCED test
+CREATE TABLE fk_notenforced_child (
+    ne_id    INTEGER PRIMARY KEY,
+    ne_p_id  INTEGER NOT NULL,
+    FOREIGN KEY (ne_p_id) REFERENCES ref_products(prod_id) NOT ENFORCED
+);
+
+-- For deferred constraint-mode tests
+CREATE TABLE fk_deferrable_child (
+    df_id   INTEGER PRIMARY KEY,
+    df_p_id INTEGER NOT NULL,
+    CONSTRAINT fk_deferrable_child_fk
+        FOREIGN KEY (df_p_id) REFERENCES ref_products(prod_id)
+        DEFERRABLE INITIALLY IMMEDIATE
+);
+
+CREATE TABLE fk_initially_deferred_child (
+    did_id   INTEGER PRIMARY KEY,
+    did_p_id INTEGER NOT NULL,
+    CONSTRAINT fk_initially_deferred_child_fk
+        FOREIGN KEY (did_p_id) REFERENCES ref_products(prod_id)
+        DEFERRABLE INITIALLY DEFERRED
+);
+
+-- For materialized view test
+CREATE MATERIALIZED VIEW mv_products AS
+SELECT prod_id, prod_name FROM ref_products;
+
+-- For function tests
+CREATE FUNCTION fn_products() RETURNS TABLE (prod_id INTEGER, prod_name TEXT)
+LANGUAGE sql AS $$ SELECT prod_id, prod_name FROM ref_products $$;
+
+SET check_function_bodies = off;
+-- accepted, reason: function body is not checked while check_function_bodies is off
+CREATE FUNCTION fn_deferrable_key_join()
+RETURNS TABLE (prod_id INTEGER, df_id INTEGER)
+LANGUAGE sql STABLE AS $$
+    SELECT ref_products.prod_id, fk_deferrable_child.df_id
+    FROM ref_products
+    JOIN fk_deferrable_child FOR KEY (df_p_id) -> ref_products (prod_id)
+$$;
+RESET check_function_bodies;
+
+-- For ghost-row clearing test
+CREATE TABLE fko_clear_a (id INTEGER PRIMARY KEY);
+CREATE TABLE fko_clear_c (id INTEGER PRIMARY KEY);
+CREATE TABLE fko_clear_b (
+    id   INTEGER PRIMARY KEY,
+    a_id INTEGER NOT NULL REFERENCES fko_clear_a(id),
+    c_id INTEGER NOT NULL REFERENCES fko_clear_c(id)
+);
+CREATE TABLE fko_clear_d (
+    id   INTEGER PRIMARY KEY,
+    b_id INTEGER NOT NULL REFERENCES fko_clear_b(id)
+);
+
+INSERT INTO ref_products VALUES (1, 'Widget'), (2, 'Gadget'), (3, 'Doohickey');
+INSERT INTO ref_locations VALUES ('US', '10001'), ('DE', '80331'), ('JP', '10000');
+INSERT INTO ref_departments VALUES (10, 'Engineering'), (20, 'Sales');
+INSERT INTO ref_teams VALUES (100, 10), (200, 20);
+
+INSERT INTO fk_orders VALUES (1, 1), (2, 1), (3, 2);
+INSERT INTO fk_reviews VALUES (10, 1), (20, 3);
+INSERT INTO fk_warehouses VALUES (1, 'US', '10001'), (2, 'DE', '80331');
+INSERT INTO fk_employees VALUES (1000, 100), (2000, 200);
+
+INSERT INTO fk_returns VALUES (1, 1), (2, NULL);
+INSERT INTO fk_inventory VALUES (1, 1), (2, 2), (3, 3);
+INSERT INTO fk_deferrable_unique_inventory VALUES (1, 1), (2, 2), (3, 3);
+
+INSERT INTO fk_notvalid_child VALUES (1, 1), (2, 2);
+INSERT INTO fk_notvalid_notnull_child VALUES (1, 1), (2, NULL);
+ALTER TABLE fk_notvalid_notnull_child
+    ADD CONSTRAINT fk_notvalid_notnull_child_nn NOT NULL nvn_p_id NOT VALID;
+INSERT INTO fk_notenforced_child VALUES (1, 1), (2, 2);
+INSERT INTO fk_deferrable_child VALUES (1, 1), (2, 2);
+INSERT INTO fk_initially_deferred_child VALUES (1, 1), (2, 2);
+
+INSERT INTO fko_clear_a VALUES (1), (2), (3), (4);
+INSERT INTO fko_clear_c VALUES (10), (20), (30);
+INSERT INTO fko_clear_b VALUES (100, 1, 10), (200, 2, 20), (300, 3, 30);
+INSERT INTO fko_clear_d VALUES (1000, 100), (2000, 200);
+
+-- ============================================================
+-- 1. Basic syntax and directions
+-- ============================================================
+
+SELECT ref_products.prod_id, fk_orders.ord_id
+FROM ref_products
+-- accepted
+JOIN fk_orders FOR KEY (ord_prod_id) -> ref_products (prod_id)
+ORDER BY ord_id;
+
+SELECT ref_products.prod_id, fk_orders.ord_id
+FROM fk_orders
+-- accepted
+JOIN ref_products FOR KEY (prod_id) <- fk_orders (ord_prod_id)
+ORDER BY ord_id;
+
+-- rejected, reason: column count mismatch
+SELECT * FROM ref_products
+JOIN fk_orders FOR KEY (ord_prod_id, ord_id) -> ref_products (prod_id);
+
+-- rejected, reason: column not found
+SELECT * FROM ref_products
+JOIN fk_orders FOR KEY (nonexistent) -> ref_products (prod_id);
+
+-- rejected, reason: no matching FK constraint (prod_name is not the referenced column)
+SELECT * FROM ref_products
+JOIN fk_orders FOR KEY (ord_prod_id) -> ref_products (prod_name);
+
+-- rejected, reason: wrong direction (fk_orders has no FK referencing ref_products that way)
+SELECT * FROM fk_orders
+JOIN ref_products FOR KEY (prod_id) -> fk_orders (ord_prod_id);
+
+-- ============================================================
+-- 2. Join types (INNER, LEFT, RIGHT, FULL)
+-- ============================================================
+
+SELECT ref_products.prod_id, fk_orders.ord_id
+FROM ref_products
+-- accepted
+JOIN fk_orders FOR KEY (ord_prod_id) -> ref_products (prod_id)
+ORDER BY ord_id;
+
+SELECT ref_products.prod_id, fk_orders.ord_id
+FROM ref_products
+-- accepted
+LEFT JOIN fk_orders FOR KEY (ord_prod_id) -> ref_products (prod_id)
+ORDER BY ref_products.prod_id, ord_id;
+
+SELECT ref_products.prod_id, fk_orders.ord_id
+FROM fk_orders
+-- accepted
+RIGHT JOIN ref_products FOR KEY (prod_id) <- fk_orders (ord_prod_id)
+ORDER BY ref_products.prod_id, ord_id;
+
+SELECT ref_products.prod_id, fk_orders.ord_id
+FROM ref_products
+-- accepted
+FULL JOIN fk_orders FOR KEY (ord_prod_id) -> ref_products (prod_id)
+ORDER BY ref_products.prod_id, ord_id;
+
+-- ============================================================
+-- 3. Composite foreign keys
+-- ============================================================
+
+SELECT ref_locations.loc_country, fk_warehouses.wh_id
+FROM ref_locations
+-- accepted
+JOIN fk_warehouses FOR KEY (wh_country, wh_zip) -> ref_locations (loc_country, loc_zip)
+ORDER BY wh_id;
+
+-- accepted, reason: swapped order on both sides
+SELECT ref_locations.loc_country, fk_warehouses.wh_id
+FROM ref_locations
+JOIN fk_warehouses FOR KEY (wh_zip, wh_country) -> ref_locations (loc_zip, loc_country)
+ORDER BY wh_id;
+
+-- rejected, reason: mismatched column order
+SELECT * FROM ref_locations
+JOIN fk_warehouses FOR KEY (wh_zip, wh_country) -> ref_locations (loc_country, loc_zip);
+
+-- ============================================================
+-- 4. Derived tables (views, subqueries, CTEs)
+-- ============================================================
+
+-- accepted, reason: subquery wrapping base table as referenced side
+SELECT q.prod_id, fk_orders.ord_id
+FROM (SELECT * FROM ref_products) AS q
+JOIN fk_orders FOR KEY (ord_prod_id) -> q (prod_id)
+ORDER BY ord_id;
+
+-- accepted, reason: subquery on referencing side
+SELECT ref_products.prod_id, q.ord_id
+FROM ref_products
+JOIN (SELECT ord_id, ord_prod_id FROM fk_orders) AS q
+    FOR KEY (ord_prod_id) -> ref_products (prod_id)
+ORDER BY q.ord_id;
+
+-- accepted, reason: nested subquery with column renames
+SELECT q.p_id, q2.o_id
+FROM (SELECT prod_id AS p_id FROM (SELECT prod_id FROM ref_products) sub) AS q
+JOIN (SELECT ord_id AS o_id, ord_prod_id AS o_pid FROM fk_orders) AS q2
+    FOR KEY (o_pid) -> q (p_id)
+ORDER BY q2.o_id;
+
+-- accepted, reason: view with column renames as referenced side
+CREATE VIEW v_products_renamed AS
+    SELECT prod_id AS pid, prod_name AS pname FROM ref_products;
+
+SELECT v.pid, fk_orders.ord_id
+FROM v_products_renamed v
+-- accepted
+JOIN fk_orders FOR KEY (ord_prod_id) -> v (pid)
+ORDER BY ord_id;
+
+-- accepted, reason: view containing FOR KEY, used as referenced side by outer FOR KEY
+CREATE VIEW v_prods_with_inventory AS
+    SELECT ref_products.prod_id, ref_products.prod_name, fk_inventory.inv_id
+    FROM ref_products
+    LEFT JOIN fk_inventory FOR KEY (inv_prod_id) -> ref_products (prod_id);
+
+SELECT v.prod_id, fk_orders.ord_id
+FROM v_prods_with_inventory v
+-- accepted
+JOIN fk_orders FOR KEY (ord_prod_id) -> v (prod_id)
+ORDER BY ord_id;
+
+-- accepted, reason: CTE chain with column renames
+WITH
+cte1 (cte_pid, cte_pname) AS (SELECT prod_id, prod_name FROM ref_products),
+cte2 (c2_pid, c2_pname) AS (SELECT cte_pid, cte_pname FROM cte1)
+SELECT c2_pid, fk_orders.ord_id
+FROM cte2
+JOIN fk_orders FOR KEY (ord_prod_id) -> cte2 (c2_pid)
+ORDER BY ord_id;
+
+-- rejected, reason: ordinary ON join in subquery does not expose key facts
+SELECT q.prod_id, fk_orders.ord_id
+FROM (
+    SELECT ref_products.prod_id
+    FROM ref_products
+    LEFT JOIN fk_inventory ON fk_inventory.inv_prod_id = ref_products.prod_id
+) q
+JOIN fk_orders FOR KEY (ord_prod_id) -> q (prod_id);
+
+-- rejected, reason: ordinary USING join in subquery does not expose key facts
+SELECT q.prod_id, fk_orders.ord_id
+FROM (
+    SELECT p.prod_id
+    FROM ref_products p
+    LEFT JOIN (SELECT inv_prod_id AS prod_id FROM fk_inventory) i USING (prod_id)
+) q
+JOIN fk_orders FOR KEY (ord_prod_id) -> q (prod_id);
+
+-- rejected, reason: ordinary CROSS JOIN with WHERE does not expose key facts
+SELECT q.prod_id, fk_orders.ord_id
+FROM (
+    SELECT ref_products.prod_id
+    FROM ref_products
+    CROSS JOIN fk_inventory
+    WHERE fk_inventory.inv_prod_id = ref_products.prod_id
+) q
+JOIN fk_orders FOR KEY (ord_prod_id) -> q (prod_id);
+
+-- rejected, reason: comma join with WHERE does not expose key facts
+SELECT q.prod_id, fk_orders.ord_id
+FROM (
+    SELECT ref_products.prod_id
+    FROM ref_products, fk_inventory
+    WHERE fk_inventory.inv_prod_id = ref_products.prod_id
+) q
+JOIN fk_orders FOR KEY (ord_prod_id) -> q (prod_id);
+
+-- rejected, reason: view with WHERE on referenced side (R violated)
+CREATE VIEW v_products_filtered AS
+    SELECT prod_id, prod_name FROM ref_products WHERE prod_id > 0;
+
+SELECT * FROM v_products_filtered v
+-- accepted
+JOIN fk_orders FOR KEY (ord_prod_id) -> v (prod_id);
+
+-- rejected, reason: subquery with LIMIT on referenced side (R violated)
+SELECT * FROM (SELECT prod_id FROM ref_products LIMIT 1) AS q
+JOIN fk_orders FOR KEY (ord_prod_id) -> q (prod_id);
+
+-- rejected, reason: TABLESAMPLE on referenced side (R violated)
+SELECT * FROM ref_products rp TABLESAMPLE SYSTEM (0)
+JOIN fk_orders FOR KEY (ord_prod_id) -> rp (prod_id);
+
+-- rejected, reason: wrapped TABLESAMPLE still does not preserve row coverage
+SELECT * FROM (SELECT prod_id FROM ref_products TABLESAMPLE SYSTEM (0)) AS q
+JOIN fk_orders FOR KEY (ord_prod_id) -> q (prod_id);
+
+-- rejected, reason: SKIP LOCKED on referenced side may remove rows at execution (R violated)
+SELECT * FROM (SELECT prod_id FROM ref_products FOR UPDATE SKIP LOCKED) AS q
+JOIN fk_orders FOR KEY (ord_prod_id) -> q (prod_id);
+
+-- rejected, reason: recursive CTE
+WITH RECURSIVE rcte AS (
+    SELECT prod_id FROM ref_products
+    UNION
+    SELECT prod_id FROM rcte
+)
+SELECT * FROM rcte JOIN fk_orders FOR KEY (ord_prod_id) -> rcte (prod_id);
+
+-- accepted, reason: two-level chain employees -> teams -> departments
+SELECT ref_departments.dept_name, ref_teams.team_id, fk_employees.emp_id
+FROM ref_departments
+JOIN
+    ref_teams JOIN fk_employees FOR KEY (emp_team_id) -> ref_teams (team_id)
+FOR KEY (team_dept_id) -> ref_departments (dept_id)
+ORDER BY emp_id;
+
+-- ============================================================
+-- 5. GROUP BY / DISTINCT / DISTINCT ON
+-- ============================================================
+
+-- accepted, reason: LEFT JOIN + GROUP BY on PK restores uniqueness
+SELECT t3.rev_id, q.prod_id, q.cnt FROM
+(
+    SELECT ref_products.prod_id, COUNT(*) AS cnt
+    FROM ref_products
+    LEFT JOIN fk_orders FOR KEY (ord_prod_id) -> ref_products (prod_id)
+    GROUP BY ref_products.prod_id
+) q
+JOIN fk_reviews t3 FOR KEY (rev_prod_id) -> q (prod_id)
+ORDER BY t3.rev_id;
+
+-- accepted, reason: DISTINCT on full PK restores uniqueness
+SELECT t3.rev_id, q.prod_id FROM
+(
+    SELECT DISTINCT ref_products.prod_id
+    FROM ref_products
+    LEFT JOIN fk_orders FOR KEY (ord_prod_id) -> ref_products (prod_id)
+) q
+JOIN fk_reviews t3 FOR KEY (rev_prod_id) -> q (prod_id)
+ORDER BY t3.rev_id;
+
+-- accepted, reason: DISTINCT ON covering PK
+SELECT t3.rev_id, q.prod_id FROM
+(
+    SELECT DISTINCT ON (ref_products.prod_id)
+        ref_products.prod_id, fk_orders.ord_id
+    FROM ref_products
+    LEFT JOIN fk_orders FOR KEY (ord_prod_id) -> ref_products (prod_id)
+) q
+JOIN fk_reviews t3 FOR KEY (rev_prod_id) -> q (prod_id)
+ORDER BY t3.rev_id;
+
+-- rejected, reason: GROUP BY on expression
+SELECT * FROM
+(
+    SELECT ref_products.prod_id + 1 AS expr_result
+    FROM ref_products
+    LEFT JOIN fk_orders FOR KEY (ord_prod_id) -> ref_products (prod_id)
+    GROUP BY ref_products.prod_id + 1
+) q
+JOIN fk_reviews FOR KEY (rev_prod_id) -> q (expr_result);
+
+-- rejected, reason: GROUP BY columns from different base tables
+SELECT * FROM
+(
+    SELECT ref_products.prod_id, fk_orders.ord_id
+    FROM ref_products
+    LEFT JOIN fk_orders FOR KEY (ord_prod_id) -> ref_products (prod_id)
+    GROUP BY ref_products.prod_id, fk_orders.ord_id
+) q
+JOIN fk_reviews FOR KEY (rev_prod_id) -> q (prod_id);
+
+-- rejected, reason: DISTINCT on partial composite PK
+SELECT * FROM
+(
+    SELECT DISTINCT loc_country
+    FROM ref_locations
+) q
+JOIN fk_warehouses FOR KEY (wh_country, wh_zip) -> q (loc_country, loc_zip);
+
+-- rejected, reason: DISTINCT ON not covering PK
+SELECT * FROM
+(
+    SELECT DISTINCT ON (fk_orders.ord_prod_id)
+        ref_products.prod_id, fk_orders.ord_id
+    FROM ref_products
+    LEFT JOIN fk_orders FOR KEY (ord_prod_id) -> ref_products (prod_id)
+) q
+JOIN fk_reviews FOR KEY (rev_prod_id) -> q (prod_id);
+
+-- ============================================================
+-- 6. Outer join null semantics
+-- ============================================================
+
+-- accepted, reason: referenced on preserved side of LEFT JOIN
+SELECT COUNT(*)
+FROM (
+    SELECT ref_products.prod_id AS ref_id
+    FROM ref_products
+    LEFT JOIN fk_inventory FOR KEY (inv_prod_id) -> ref_products (prod_id)
+) q
+JOIN fk_orders FOR KEY (ord_prod_id) -> q (ref_id);
+
+-- accepted, reason: FULL JOIN may null-extend ref_products, but ghost rows with NULL
+-- ref_id never match the outer FK equi-join condition, so they neither
+-- break uniqueness on q.ref_id nor prevent any fk_orders row from
+-- finding its match.
+SELECT COUNT(*)
+FROM (
+    SELECT ref_products.prod_id AS ref_id
+    FROM ref_products
+    FULL JOIN fk_returns FOR KEY (ret_prod_id) -> ref_products (prod_id)
+) q
+JOIN fk_orders FOR KEY (ord_prod_id) -> q (ref_id);
+
+-- rejected, reason: FULL JOIN can null-extend the referencing side too, so
+-- a NOT NULL fact on a referencing column must not be propagated through it.
+-- fko_clear_a has id=4 with no matching fko_clear_b, so the FULL JOIN emits
+-- a row with fko_clear_b.c_id IS NULL.  An INNER key join chained on c_id
+-- would silently drop that null-extended row, violating preservation.
+SELECT *
+FROM fko_clear_a
+FULL JOIN fko_clear_b FOR KEY (a_id) -> fko_clear_a (id)
+JOIN fko_clear_c FOR KEY (id) <- fko_clear_b (c_id);
+
+-- rejected, symmetric form: referencing on LEFT
+SELECT *
+FROM fko_clear_b
+FULL JOIN fko_clear_a FOR KEY (id) <- fko_clear_b (a_id)
+JOIN fko_clear_c FOR KEY (id) <- fko_clear_b (c_id);
+
+-- accepted, sanity: INNER preserved-side JOIN does not null-extend, so the
+-- chained INNER key join on c_id is still provable.
+SELECT *
+FROM fko_clear_a
+JOIN fko_clear_b FOR KEY (a_id) -> fko_clear_a (id)
+JOIN fko_clear_c FOR KEY (id) <- fko_clear_b (c_id)
+ORDER BY fko_clear_b.id;
+
+-- rejected, reason: non-unique key join inside subquery (U violated)
+SELECT * FROM
+(
+    SELECT ref_products.prod_id, fk_orders.ord_id
+    FROM ref_products
+    JOIN fk_orders FOR KEY (ord_prod_id) -> ref_products (prod_id)
+) q
+JOIN fk_reviews FOR KEY (rev_prod_id) -> q (prod_id);
+
+-- rejected, reason: INNER JOIN inside subquery removes rows (R violated)
+SELECT * FROM
+(
+    SELECT ref_products.prod_id
+    FROM ref_products
+    JOIN fk_orders FOR KEY (ord_prod_id) -> ref_products (prod_id)
+    GROUP BY ref_products.prod_id
+) q
+JOIN fk_reviews FOR KEY (rev_prod_id) -> q (prod_id);
+
+-- ============================================================
+-- 8. Constraint enforcement and timing
+-- ============================================================
+
+-- rejected, reason: NOT ENFORCED FK rejected
+SELECT ref_products.prod_id, fk_notenforced_child.ne_id
+FROM ref_products
+JOIN fk_notenforced_child FOR KEY (ne_p_id) -> ref_products (prod_id)
+ORDER BY ne_id;
+
+-- rejected, reason: NOT VALID FK rejected
+SELECT * FROM ref_products
+JOIN fk_notvalid_child FOR KEY (nv_p_id) -> ref_products (prod_id);
+
+-- rejected, reason: NOT VALID NOT NULL rejected
+SELECT * FROM ref_products
+JOIN fk_notvalid_notnull_child FOR KEY (nvn_p_id) -> ref_products (prod_id);
+
+-- accepted, reason: NOT DEFERRABLE key join works even after SET CONSTRAINTS ALL DEFERRED
+BEGIN;
+SET CONSTRAINTS ALL DEFERRED;
+SELECT ref_products.prod_id, fk_orders.ord_id
+FROM ref_products
+-- accepted
+JOIN fk_orders FOR KEY (ord_prod_id) -> ref_products (prod_id)
+ORDER BY ord_id;
+ROLLBACK;
+
+-- rejected, reason: DEFERRABLE constraint rejected even when currently IMMEDIATE
+SELECT ref_products.prod_id, fk_deferrable_child.df_id
+FROM ref_products
+JOIN fk_deferrable_child FOR KEY (df_p_id) -> ref_products (prod_id)
+ORDER BY df_id;
+
+-- rejected, reason: stored key join rejected when it depends on a DEFERRABLE constraint
+CREATE VIEW v_deferrable_key_join AS
+SELECT ref_products.prod_id, fk_deferrable_child.df_id
+FROM ref_products
+JOIN fk_deferrable_child FOR KEY (df_p_id) -> ref_products (prod_id);
+
+-- rejected, reason: prepared key join rejected when it depends on a DEFERRABLE constraint
+PREPARE p_deferrable_key_join AS
+SELECT ref_products.prod_id, fk_deferrable_child.df_id
+FROM ref_products
+JOIN fk_deferrable_child FOR KEY (df_p_id) -> ref_products (prod_id)
+ORDER BY df_id;
+
+-- rejected, reason: cursor key join rejected when it depends on a DEFERRABLE constraint
+BEGIN;
+DECLARE c_deferrable_key_join CURSOR FOR
+SELECT ref_products.prod_id, fk_deferrable_child.df_id
+FROM ref_products
+-- rejected, reason: cursor query depends on a DEFERRABLE FK
+JOIN fk_deferrable_child FOR KEY (df_p_id) -> ref_products (prod_id)
+ORDER BY df_id;
+ROLLBACK;
+
+-- rejected, reason: pulled-up subquery key join rejected at PREPARE
+PREPARE p_pulled_up_deferrable_key_join AS
+SELECT q.prod_id, q.df_id
+FROM (
+    SELECT ref_products.prod_id, fk_deferrable_child.df_id
+    FROM ref_products
+    JOIN fk_deferrable_child FOR KEY (df_p_id) -> ref_products (prod_id)
+) q
+ORDER BY df_id;
+
+-- rejected, reason: inlined SQL function key join rejected when the body is planned
+PREPARE p_inlined_deferrable_key_join AS
+SELECT * FROM fn_deferrable_key_join()
+ORDER BY df_id;
+
+EXECUTE p_inlined_deferrable_key_join;
+
+DEALLOCATE p_inlined_deferrable_key_join;
+
+-- rejected, reason: DEFERRABLE constraint rejected when currently DEFERRED
+BEGIN;
+SET CONSTRAINTS fk_deferrable_child_fk DEFERRED;
+SELECT * FROM ref_products
+-- rejected, reason: DEFERRABLE FK is currently deferred
+JOIN fk_deferrable_child FOR KEY (df_p_id) -> ref_products (prod_id);
+ROLLBACK;
+
+-- rejected, reason: INITIALLY DEFERRED constraint rejected by default
+BEGIN;
+SELECT * FROM ref_products
+-- rejected, reason: FK is INITIALLY DEFERRED
+JOIN fk_initially_deferred_child FOR KEY (did_p_id) -> ref_products (prod_id);
+ROLLBACK;
+
+-- rejected, reason: INITIALLY DEFERRED constraint rejected even after SET CONSTRAINTS IMMEDIATE
+BEGIN;
+SET CONSTRAINTS fk_initially_deferred_child_fk IMMEDIATE;
+SELECT ref_products.prod_id, fk_initially_deferred_child.did_id
+FROM ref_products
+-- rejected, reason: INITIALLY DEFERRED FK remains unusable for key joins
+JOIN fk_initially_deferred_child FOR KEY (did_p_id) -> ref_products (prod_id)
+ORDER BY did_id;
+ROLLBACK;
+
+-- rejected, reason: DEFERRABLE UNIQUE does not preserve referenced-side uniqueness
+SELECT q.prod_id, fk_reviews.rev_id
+FROM (
+    SELECT ref_products.prod_id
+    FROM ref_products
+    LEFT JOIN fk_deferrable_unique_inventory
+        FOR KEY (dui_prod_id) -> ref_products (prod_id)
+) q
+JOIN fk_reviews FOR KEY (rev_prod_id) -> q (prod_id);
+
+-- ============================================================
+-- 9. Unsupported relation kinds
+-- ============================================================
+
+-- rejected, reason: function as FK source
+SELECT * FROM ref_products
+JOIN fn_products() AS f FOR KEY (prod_id) -> ref_products (prod_id);
+
+-- rejected, reason: materialized view
+SELECT * FROM mv_products
+JOIN fk_orders FOR KEY (ord_prod_id) -> mv_products (prod_id);
+
+-- rejected, reason: UNION ALL in derived table
+SELECT * FROM ref_products
+JOIN (
+    SELECT ord_id, ord_prod_id FROM fk_orders
+    UNION ALL
+    SELECT ord_id, ord_prod_id FROM fk_orders
+) AS u FOR KEY (ord_prod_id) -> ref_products (prod_id);
+
+-- ============================================================
+-- 10. Deep multi-feature stress test
+-- ============================================================
+
+-- accepted, reason: deeply-nested query stress-testing key join resolution
+--
+-- The key join analysis must drill through 6 levels to reach the base table:
+--   Level 1: ref_products (base table with PK prod_id)
+--   Level 2: LEFT JOIN with fk_orders (subquery on referencing side, column renamed)
+--            + GROUP BY on base-table PK restoring uniqueness
+--   Level 3: subquery wrapper with column rename
+--   Level 4: subquery wrapper with column rename
+--   Level 5: subquery wrapper with column rename
+--   Level 6: outer key join with fk_reviews through all layers
+--
+SELECT rev.rev_id, deep.p_id, deep.order_count
+FROM (
+    -- Level 5: rename layer 3
+    SELECT l4_id AS p_id, l4_cnt AS order_count
+    FROM (
+        -- Level 4: rename layer 2
+        SELECT l3_id AS l4_id, l3_cnt AS l4_cnt
+        FROM (
+            -- Level 3: rename layer 1
+            SELECT grp_id AS l3_id, grp_cnt AS l3_cnt
+            FROM (
+                -- Level 2: GROUP BY on base-table PK restoring uniqueness
+                SELECT ref_products.prod_id AS grp_id, COUNT(*) AS grp_cnt
+                FROM ref_products                  -- Level 1: base table
+                LEFT JOIN (
+                    -- subquery on referencing side with column rename
+                    SELECT ord_prod_id AS opid FROM fk_orders
+                ) ord_q FOR KEY (opid) -> ref_products (prod_id)
+                GROUP BY ref_products.prod_id
+            ) grp
+        ) l3
+    ) l4
+) deep
+-- Level 6: final key join reaching through all rename layers
+JOIN fk_reviews rev FOR KEY (rev_prod_id) -> deep (p_id)
+ORDER BY rev.rev_id;
+
+-- accepted, reason: view-with-FK-join resolution chain + nested FK chain with <- direction
+-- FROM: deep -> l2 -> l1 -> v_prods_with_inventory (view with key join inside)
+-- SELECT: scalar subquery with 3-level chain using <- at inner level
+SELECT rev.rev_id, deep.p_id,
+       (SELECT COUNT(*)
+        FROM ref_departments
+        JOIN (
+            fk_employees
+            JOIN ref_teams FOR KEY (team_id) <- fk_employees (emp_team_id)
+        ) FOR KEY (team_dept_id) -> ref_departments (dept_id)
+       ) AS emp_count
+FROM (
+    SELECT l2_id AS p_id FROM (
+        SELECT l1_id AS l2_id FROM (
+            SELECT prod_id AS l1_id FROM v_prods_with_inventory
+        ) l1
+    ) l2
+) deep
+JOIN fk_reviews rev FOR KEY (rev_prod_id) -> deep (p_id)
+ORDER BY rev.rev_id;
+
+-- rejected, reason: row-filtering WHERE buried under rename layers (R violated)
+SELECT rev.rev_id, deep.p_id
+FROM (
+    SELECT l3_id AS p_id FROM (
+        SELECT l2_id AS l3_id FROM (
+            SELECT l1_id AS l2_id FROM (
+                SELECT prod_id AS l1_id
+                FROM ref_products
+                WHERE prod_name <> 'Widget'
+            ) l1
+        ) l2
+    ) l3
+) deep
+JOIN fk_reviews rev FOR KEY (rev_prod_id) -> deep (p_id)
+ORDER BY rev.rev_id;
+
+-- ============================================================
+-- Cleanup
+-- ============================================================
+
+DROP VIEW v_prods_with_inventory;
+DROP VIEW v_products_renamed;
+DROP VIEW v_products_filtered;
+
+DROP FUNCTION fn_products();
+DROP FUNCTION fn_deferrable_key_join();
+DROP MATERIALIZED VIEW mv_products;
+
+DROP TABLE fko_clear_d;
+DROP TABLE fko_clear_b;
+DROP TABLE fko_clear_c;
+DROP TABLE fko_clear_a;
+
+DROP TABLE fk_initially_deferred_child;
+DROP TABLE fk_deferrable_child;
+DROP TABLE fk_notenforced_child;
+DROP TABLE fk_notvalid_notnull_child;
+DROP TABLE fk_notvalid_child;
+DROP TABLE fk_deferrable_unique_inventory;
+DROP TABLE fk_inventory;
+DROP TABLE fk_returns;
+DROP TABLE fk_employees;
+DROP TABLE fk_warehouses;
+DROP TABLE fk_reviews;
+DROP TABLE fk_orders;
+DROP TABLE ref_teams;
+DROP TABLE ref_departments;
+DROP TABLE ref_locations;
+DROP TABLE ref_products;
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 8cf40c87043..45f54626286 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -836,6 +836,7 @@ FDWCollateState
 FD_SET
 FILE
 FILETIME
+FKFilterRemapContext
 FPI
 FSMAddress
 FSMPage
@@ -1537,6 +1538,21 @@ KAXCompressReason
 KeyAction
 KeyActions
 KeyArray
+KeyJoinClause
+KeyJoinColumn
+KeyJoinDirection
+KeyJoinFact
+KeyJoinFactContext
+KeyJoinFactKind
+KeyJoinInactiveReason
+KeyJoinKeyPosition
+KeyJoinMatch
+KeyJoinNode
+KeyJoinProofDependency
+KeyJoinQueryStack
+KeyJoinReq
+KeyJoinRowCollapse
+KeyJoinSurfaceFacts
 KeySuffix
 KeyWord
 LARGE_INTEGER
-- 
2.52.0

