# User-visible defects in commit 62c3b4c (postgres_fdw ArrayCoerceExpr pushdown), still present in master

Audit of 62c3b4c "Support for deparsing of ArrayCoerceExpr node in contrib/postgres_fdw"
(Alexander Korotkov, 2025-07-18). Code is functionally unchanged in master (only comment-typo
fix 4c5159a since) and is in REL_19_STABLE, so these ship in PostgreSQL 19 unless fixed.

Method: 23-agent workflow — 12 independent finder lenses (23 raw findings) → root-cause
dedup (3 clusters) → per-cluster adversarial refuter + independent end-to-end tracer + live
reproduction on a server built from this tree (master @ a8c2547, PG 19beta1; loopback FDW
with a second database as the "remote", so local-only catalog objects genuinely don't exist
remotely; ground truth = forced-local evaluation of the identical predicate) → completeness
critic. **All three defects: refuter confirmed, tracer confirmed, live-reproduced.**

---

## 1. (high, wrong results) `foreign_expr_walker` never vets the element-coercion pathway (`elemexpr`)

**Anchor:** `contrib/postgres_fdw/deparse.c:706-733` (walker case recurses only into `e->arg`, line 713)

The per-element conversion — a `FuncExpr` of the `pg_cast` function, a `CoerceViaIO`, or a
`RelabelType` — lives in `e->elemexpr` and is never examined. Unlike `T_FuncExpr` (:566),
`T_OpExpr` (:614), and `T_ScalarArrayOpExpr` (:654), no `is_shippable()` check touches the
function that gives the cast its semantics; the `:1050` exit vets only the result *array type*.
The would-be backstop `contain_mutable_functions` (`is_foreign_expr`, :287) reaches `elemexpr`
via `expression_tree_walker` but checks only volatility, and IMMUTABLE is the conventional
declaration for cast functions. `deparseArrayCoerceExpr` (:3544-3556) then ships bare
`arg::resulttype` (or nothing for implicit format), so the remote re-resolves the element
coercion against *its* `pg_cast` catalog. Pre-commit, all of this was evaluated locally
(walker default case rejected the node).

Violates the documented contract (postgres-fdw.sgml ~1168-1173: only built-in /
listed-extension types, operators, functions are shipped) and is inconsistent with the
scalar form of the identical cast, which the `T_FuncExpr` shippability check still keeps local.

Live-reproduced (all observed on PG 19beta1):

```sql
-- Remote database "remotedb" (stock, only tables):
CREATE TABLE public.t_text (id int, c text[]);
INSERT INTO t_text VALUES (1,'{12345}'), (2,'{999}');
CREATE TABLE public.t_s (id int, c text);
INSERT INTO t_s VALUES (1,'00005'), (2,'5');

-- Local database:
CREATE EXTENSION postgres_fdw;
CREATE SERVER loopback FOREIGN DATA WRAPPER postgres_fdw OPTIONS (dbname 'remotedb');
CREATE USER MAPPING FOR CURRENT_USER SERVER loopback;
CREATE FOREIGN TABLE ft_text (id int, c text[]) SERVER loopback OPTIONS (table_name 't_text');
CREATE FOREIGN TABLE ft_s    (id int, c text)   SERVER loopback OPTIONS (table_name 't_s');

-- (A) Explicit-cast form: silent wrong results
CREATE FUNCTION text2int(text) RETURNS int LANGUAGE plpgsql IMMUTABLE STRICT
  AS 'BEGIN RETURN length($1); END';
CREATE CAST (text AS integer) WITH FUNCTION text2int(text);

EXPLAIN (VERBOSE, COSTS OFF) SELECT id, c FROM ft_text WHERE c::int[] = ARRAY[5];
--   Remote SQL: SELECT id, c FROM public.t_text WHERE ((c::integer[] = '{5}'::integer[]))
SELECT id, c FROM ft_text WHERE c::int[] = ARRAY[5];
--   Observed: 0 rows.  Correct (pre-commit/local semantics, cf.
--   SELECT * FROM (SELECT * FROM ft_text OFFSET 0) s WHERE c::int[] = ARRAY[5]):
--   (1,'{12345}') because length('12345') = 5.
SELECT id, c FROM ft_text WHERE c::int[] = ARRAY[12345];
--   Observed: spurious row (1,'{12345}'); locally: 0 rows.

-- (B) New hard error on data the local cast handles:
--   on remote: INSERT INTO t_text VALUES (3,'{abc}');
SELECT id FROM ft_text WHERE c::int[] = ARRAY[5];
--   Observed: ERROR: invalid input syntax for type integer: "abc"
--   CONTEXT: remote SQL command: SELECT id FROM public.t_text WHERE ((c::integer[] = '{5}'::integer[]))
--   Locally-evaluated control returns id=1.

-- (C) Implicit-format branch (cast omitted entirely; see also defect 3):
CREATE FUNCTION int2padded(int) RETURNS text LANGUAGE sql IMMUTABLE STRICT
  AS $$ SELECT lpad(CAST($1 AS varchar), 5, '0') $$;
CREATE CAST (integer AS text) WITH FUNCTION int2padded(int) AS IMPLICIT;
SET plan_cache_mode = force_generic_plan;
PREPARE s(int[]) AS SELECT id FROM ft_s WHERE c = ANY($1);
EXPLAIN (VERBOSE, COSTS OFF) EXECUTE s(ARRAY[5]);
--   Remote SQL: SELECT id FROM public.t_s WHERE ((c = ANY ($1::integer[])))
EXECUTE s(ARRAY[5]);
--   Observed: ERROR: operator does not exist: text = integer  (remote SQL command)
--   Correct/pre-commit: id=1 (verified via OFFSET 0 control).

-- Control demonstrating the inconsistency: the identical cast in scalar form stays local
EXPLAIN (VERBOSE, COSTS OFF) SELECT id FROM ft_text WHERE c[1]::int = 5;
--   Filter: ((ft_text.c[1])::integer = 5)   (local; correct results)
```

Because pushed-down quals also gate direct UPDATE/DELETE, the wrong-row selection extends to
wrong rows *modified*.

---

## 2. (high, wrong results) Builtin GUC-sensitive I/O coercions shipped via `elemexpr`'s `CoerceViaIO` — zero user-defined objects needed

**Anchor:** `contrib/postgres_fdw/deparse.c:706` (same unvetted-`elemexpr` hole, distinct fix implication)

postgres_fdw deliberately never ships scalar `T_CoerceViaIO` (absent from walker and
`deparseExpr` dispatch), so `f8col::text` stays a local Filter. But `f8arr::text[]` is an
ArrayCoerceExpr whose `elemexpr` is a `CoerceViaIO` (string-category I/O fallback,
parse_coerce.c:3275-3283) — and it's now judged shippable: `float8out`/`byteaout`/`textin`
are marked immutable in pg_proc.dat despite depending on `extra_float_digits` /
`bytea_output`. postgres_fdw itself forces `SET extra_float_digits = 3` on every remote
session (connection.c:840) and never syncs `bytea_output`, so local-vs-remote rendering
diverges deterministically.

Key fix implication: **an `is_shippable()` check on the `elemexpr` function OID would NOT
catch this** — the I/O functions are builtin. The walker must refuse non-RelabelType element
coercions (or the deparser must stop delegating them).

Live-reproduced:

```sql
-- Remote (stock): CREATE TABLE public.t1 (id int, f8 float8[], txt text[]);
--                 INSERT INTO t1 VALUES (1, ARRAY[(0.1::float8 + 0.2::float8)], ARRAY['0.3']);
-- Local: CREATE FOREIGN TABLE ft1 (id int, f8 float8[], txt text[]) SERVER loopback OPTIONS (table_name 't1');
SET extra_float_digits = 0;
SELECT id FROM ft1 WHERE f8::text[] = txt;
--   Observed on master: 0 rows.  Remote SQL: SELECT id FROM public.t1 WHERE ((f8::text[] = txt))
--   (remote session renders '{0.30000000000000004}' under forced extra_float_digits=3)
--   Correct (pre-commit local semantics, cf. WHERE (f8::text[] = txt OR random() < -1)): id=1.

-- bytea variant: remote ALTER DATABASE ... SET bytea_output='escape', local default 'hex':
--   WHERE barr::text[] = txt → 0 rows pushed down vs id=1 locally.

-- Control: scalar WHERE txt[1] = f8[1]::text stays a local Filter and is correct.
```

---

## 3. (medium, remote errors) `deparseArrayCoerceExpr` omits `COERCE_IMPLICIT_CAST` coercions entirely

**Anchor:** `contrib/postgres_fdw/deparse.c:3552`

Emitting no text for implicit-format coercions bets that the remote parser re-derives the
same coercion during operator resolution. That bet fails independently of any `elemexpr`
vetting fix:

- **(a)** local implicit pg_cast entry backed by a *builtin* function — every involved OID is
  shippable, yet the remote errors:

```sql
-- Remote (stock): CREATE TABLE public.t (c2 text, c3 int[]); INSERT INTO t VALUES ('3','{3}');
-- Local:
CREATE FOREIGN TABLE ft (c2 text, c3 int[]) SERVER loopback OPTIONS (table_name 't');
CREATE CAST (integer AS text) WITH FUNCTION pg_catalog.to_hex(integer) AS IMPLICIT;
SELECT count(*) FROM ft WHERE c2 = ANY (c3);
--   Remote SQL: SELECT count(*) FROM public.t WHERE ((c2 = ANY (c3)))   -- coercion vanished
--   Observed: ERROR: operator does not exist: text = integer
--   Correct/pre-commit (to_hex(3)='3'): count = 1.
-- Same via generic plan: PREPARE p(int[]) AS ... WHERE c2 = ANY($1)
--   → Remote SQL ((c2 = ANY ($1::integer[]))), identical error.
```

- **(b)** declared-type mismatch, *no user objects at all*: remote column is an enum, local
  foreign table declares `char(1)`; `PREPARE s(varchar[]) ... WHERE d = ANY($1)` ships
  `d = ANY ($1::character varying[])` and fails
  `operator does not exist: public.enum_of_int_like = character varying`, where pre-commit
  it returned 2 rows via a local Filter. This mode was demonstrated in-thread by the patch
  author and flagged by a reviewer pre-commit, then consciously accepted.

Rated medium: loud error rather than silent corruption, and the scalar implicit-cast FuncExpr
path has long had the same omission policy (this commit extends the exposure to array
coercions). Unlike `RelabelType`, an ArrayCoerceExpr is not pure relabeling, so the omission
drops real semantics; a remote can also bind a semantically different operator (e.g. texteq
vs bpchareq trailing-space handling) rather than erroring.

---

## Follow-up lead from the completeness critic (traced in code, not live-run)

**Array-over-domain casts push a hidden `CoerceToDomain` to the remote** (medium confidence).
When the target element type is a domain, `elemexpr` contains `CoerceToDomain` — the one
shape that evades even the volatility backstop: `check_functions_in_node` has no
`T_CoerceToDomain` case and domain CHECK expressions live in the catalog, not the node tree.
With the domain in a server-listed extension, `is_shippable(resulttype)` passes (implicit
array types are extension members, pg_type.c:628-635), so `carr::dom[]` ships while scalar
`c::dom` never does, and the *remote's* domain constraints (remote catalog state, remote
session GUCs) replace local enforcement — contradicting clauses.c:3428-3440's rationale for
not const-folding through CoerceToDomain. Traced failure modes: `unrecognized configuration
parameter` remote errors for GUC-reading constraints; silently weaker constraint enforcement
under local ALTER DOMAIN / extension version skew. Mitigating: requires the `extensions`
option, whose docs shift identical-behavior responsibility to the user.

## Checked and found clean / derivative

- **Collation logic** (deparse.c:719-731): exact parity with `T_RelabelType`; `elemexpr`
  cannot introduce a Var-derived collation (`CaseTestExpr` is built with InvalidOid collation,
  parse_coerce.c:947). Affirmatively clean.
- **ORDER BY / GROUP BY / aggregate pushdown, direct UPDATE/DELETE, EPQ rechecks**: traced;
  only derivative manifestations of defects 1-2 (wrong sort/grouping keys, wrong rows
  modified, nondeterministic row drops when a diverging pushed qual is re-evaluated locally
  in an EPQ recheck), not new defect classes.
- **Fix shape implied by the evidence**: restrict pushdown to element coercions that are pure
  relabelings (elemexpr is RelabelType/bare CaseTestExpr) — and if kept, always emit the cast
  rather than omitting implicit format; vetting only the elemexpr function OID is insufficient
  (defect 2), and omission is unsound even when everything is shippable (defect 3).

## Provenance

- Workflow `wf_16d96442-0cf`: 23 agents (cap 30), 12/12 finder lenses returned, 23 raw
  findings → 3 root causes, 0 dropped, 0 refuted. Each root cause: adversarial refuter
  **confirmed** + independent end-to-end tracer **confirmed** + empirical reproducer
  **reproduced=yes**. Archives lens independently matched defect 3(b) to the pre-commit
  review thread.
- Empirical setup: reused `tmp_install` of this tree (PG 19beta1, master a8c2547); fresh
  cluster in session scratchpad, unix-socket only; "remote" = second database over a loopback
  postgres_fdw server. Servers stopped afterward; socket dirs removed; data dirs left in
  scratchpad; git tree untouched (`tmp_install/` is gitignored).
- Full machine-readable results: `/tmp/claude-1000/-home-nm-src-pg-postgresql/afe6d0ac-a2cb-4fa6-9526-676825c85665/tasks/wlbjtr33p.output`
