# User-visible defects in `FOR PORTION OF` still present in master

Feature under test: commit `8e72d91` ("Add UPDATE/DELETE FOR PORTION OF",
2026-04-01).
Baseline: `master` = `0b776de` (2026-09-03), PostgreSQL 20devel, built with
`-Dcassert=true -Ddebug=true -Doptimization=0`.

Four user-visible defects were found and are covered by new regression tests
in `src/test/regress/{sql,expected}/for_portion_of.sql`.  Nine further
findings that were not suitable for a regression test are described in
"Defects not covered by a test" below.

All 19 published follow-up fixes to `8e72d91` are already contained in
`0b776de` (verified with `git merge-base --is-ancestor`), so none of the
findings below is an already-fixed bug.

Every finding below was reproduced by hand on an unmodified `0b776de` before
being written down.  Findings reported by the hunt agents that did *not*
survive that check are listed in "Rejected candidate findings".

---

## Summary

| # | Defect | Severity | Tested |
|---|--------|----------|--------|
| D1 | `FOR PORTION OF` is **silently ignored** on a view with an `INSTEAD` rule: the whole row is overwritten, or the whole row is deleted | **Data loss** | yes |
| D2 | `FROM`/`TO` bounds do not resolve untyped parameters, breaking `PREPARE` and the extended query protocol | High | yes |
| D3 | `EXPLAIN (GENERIC_PLAN)` fails with `no value found for parameter 1` | Medium | yes |
| D4 | Plain `EXPLAIN` raises the run-time error `FOR PORTION OF target must not be null` | Medium | yes |
| D5 | `elog(ERROR, "unexpected opcintype")` — an `XX000` internal error — for a range column whose default GiST opclass has a concrete `opcintype` | High | no |
| D6 | Statement-level `INSERT` triggers for leftovers fire on the wrong table under plain inheritance | Medium | no |
| D7 | `AFTER` row triggers on leftovers fire mid-statement, causing a spurious error whose `HINT` cannot be followed | Medium | no |
| D8 | A `BEFORE INSERT` trigger returning `NULL` silently discards leftovers, destroying data the statement was supposed to preserve | Medium | no |
| D9 | Bound expressions are evaluated four times per row at three independent sites; divergent values yield overlapping or gapped history | Medium | no |
| D10 | Plain `EXPLAIN` evaluates the target expression, running user functions | Low | no |
| D11 | `WITH CHECK OPTION` violations blame the leftover row instead of the row the user's `SET` produced | Low | no |
| D12 | `pg_get_ruledef` deparses a `NULL` bound as `NULL::unknown` | Cosmetic | no |
| D13 | `UPDATE`/`DELETE` reference pages omit the privileges `FOR PORTION OF` requires on the range column | Doc | no |
| D14 | Misleading `multiple assignments to same column` error through a view that exposes the range column twice | Low | no |

---

## Defects covered by a test

### D1 — `FOR PORTION OF` is silently ignored on a view with an `INSTEAD` rule

This is the most serious finding: it destroys data with no error and a
command tag that claims success.

```sql
CREATE TABLE t (id int, valid_at daterange, name text);
INSERT INTO t VALUES (1, daterange('2018-01-01', '2020-01-01'), 'one');
CREATE VIEW v AS SELECT * FROM t;
CREATE RULE v_upd AS ON UPDATE TO v
  DO INSTEAD UPDATE t SET name = NEW.name WHERE id = OLD.id;
CREATE RULE v_del AS ON DELETE TO v
  DO INSTEAD DELETE FROM t WHERE id = OLD.id;

UPDATE v FOR PORTION OF valid_at FROM '2018-06-01' TO '2019-01-01' SET name = 'foo';
UPDATE 1
SELECT * FROM t;
 id |        valid_at         | name
----+-------------------------+------
  1 | [2018-01-01,2020-01-01) | foo      -- the whole row was renamed
```

The row was not split and its range was not truncated: `FOR PORTION OF` had
no effect whatsoever.  `DELETE` is worse — it removes the entire row instead
of carving out the requested portion:

```sql
DELETE FROM v FOR PORTION OF valid_at FROM '2018-06-01' TO '2019-01-01';
DELETE 1
SELECT * FROM t;
 id | valid_at | name
----+----------+------
(0 rows)                             -- the row is gone entirely
```

`DO INSTEAD NOTHING` likewise swallows the clause.  The identical statement
against a plain auto-updatable view (no rule) behaves correctly and produces
the expected three-way split, which is what makes this a silent trap rather
than an obvious limitation.

Impact: a temporal history table exposed through a rule-based updatable view
loses history.  There is no error, no warning, and the command tag reports
one affected row, so nothing signals to the application that the temporal
qualifier was dropped.

The test asserts the clause is *rejected*, mirroring the error PostgreSQL
already raises for the analogous unsupported case (`views with INSTEAD OF
triggers do not support FOR PORTION OF`, commit `dfce19c`).  A `DO ALSO`
rule leaves the original query in place and is verified to keep working.

### D2 — `FOR PORTION OF ... FROM $1 TO $2` cannot infer parameter types

`PREPARE` (and, equivalently, `PQprepare`/`PQexecParams` with unspecified
parameter OIDs, which is what most client drivers send) fails:

```sql
PREPARE p AS UPDATE t FOR PORTION OF valid_at FROM $1 TO $2 SET name = 'q';
ERROR:  could not determine data type of parameter $1
LINE 1: PREPARE p AS UPDATE t FOR PORTION OF valid_at FROM $1 TO $2 ...
                                                           ^
```

Every comparable construct resolves the parameter type from context:

```sql
-- the hand-written equivalent of the same clause: works
PREPARE c AS UPDATE t SET name = 'q' WHERE valid_at && daterange($1, $2);
SELECT parameter_types FROM pg_prepared_statements WHERE name = 'c';
 {date,date}

-- the other FOR PORTION OF syntax: also works
PREPARE q AS UPDATE t FOR PORTION OF valid_at ($1) SET name = 'q';
 {daterange}
```

`DELETE` is affected identically, and a single parameterized bound
(`FROM $1 TO '2003-01-01'`) is enough to trigger it.

Impact: the documented `FROM`/`TO` spelling of `FOR PORTION OF` — the one
shown in the `UPDATE` and `DELETE` reference pages and in `dml.sgml` — cannot
be used with parameters unless the client declares the parameter types
explicitly.  The workaround is `PREPARE p(date, date) AS ...` or an explicit
cast on each bound.

### D3 — `EXPLAIN (GENERIC_PLAN)` fails

```sql
EXPLAIN (COSTS OFF, GENERIC_PLAN)
  UPDATE t FOR PORTION OF valid_at ($1::daterange) SET name = 'q';
ERROR:  no value found for parameter 1
```

`GENERIC_PLAN` exists specifically so that a statement containing parameter
placeholders can be planned with no parameter values supplied, and it works
for the identical qual written by hand:

```sql
EXPLAIN (COSTS OFF, GENERIC_PLAN)
  UPDATE t SET name = 'q' WHERE valid_at && $1::daterange;
 Update on t
   ->  Seq Scan on t
         Filter: (valid_at && $1)
```

`DELETE ... FOR PORTION OF` is affected identically.  (With the `FROM`/`TO`
spelling the statement fails earlier, with D2's error.)

### D4 — Plain `EXPLAIN` raises a run-time error

```sql
EXPLAIN (COSTS OFF)
  UPDATE t FOR PORTION OF valid_at (NULL::daterange) SET name = 'q';
ERROR:  FOR PORTION OF target must not be null
```

`EXPLAIN` without `ANALYZE` must not execute the statement, so it should
print the plan; the null check belongs to execution only.  `DELETE` behaves
the same way.  Executing the statement (correctly) still raises the error.

---

## Defects not covered by a test

### D5 — `XX000 unexpected opcintype` from ordinary SQL

`transformForPortionOfClause()` switches on the range column's default GiST
opclass `opcintype` and assumes it is polymorphic (`anyrange` /
`anymultirange`), falling through to `elog(ERROR, "unexpected opcintype")`
otherwise.  A user-defined default opclass over a *concrete* range type is
perfectly legal and reaches that branch:

```sql
CREATE FUNCTION my_int4range_overlaps(int4range, int4range) RETURNS bool AS
  $$ SELECT $1 OPERATOR(pg_catalog.&&) $2 $$ LANGUAGE SQL IMMUTABLE STRICT;
CREATE OPERATOR && (leftarg = int4range, rightarg = int4range,
  procedure = my_int4range_overlaps, commutator = &&,
  restrict = rangesel, join = areajoinsel);

CREATE OPERATOR CLASS my_int4range_gist_ops
    DEFAULT FOR TYPE int4range USING gist AS
    OPERATOR 3 &&(int4range, int4range),
    FUNCTION 1 range_gist_consistent(internal, anyrange, smallint, oid, internal),
    FUNCTION 2 range_gist_union(internal, internal),
    FUNCTION 5 range_gist_penalty(internal, internal, internal),
    FUNCTION 6 range_gist_picksplit(internal, internal),
    FUNCTION 7 range_gist_same(anyrange, anyrange, internal);
-- must be registered as ("any","any"): gistutil.c looks it up with
-- get_opfamily_proc(opfamily, ANYOID, ANYOID, GIST_TRANSLATE_CMPTYPE_PROC)
ALTER OPERATOR FAMILY my_int4range_gist_ops USING gist
    ADD FUNCTION 12 ("any", "any") gist_translate_cmptype_common(integer);

CREATE TABLE t57 (id int, valid_at int4range, name text);
INSERT INTO t57 VALUES (1, int4range(1,10), 'a');

UPDATE t57 SET name = 'c' WHERE valid_at && int4range(3,5);   -- UPDATE 1, fine
UPDATE t57 FOR PORTION OF valid_at FROM 3 TO 5 SET name = 'b';
ERROR:  XX000: unexpected opcintype: 3904
LOCATION:  transformForPortionOfClause, analyze.c:1536
```

`DELETE ... FOR PORTION OF` fails identically.  `XX000` with an
`elog`-style message is by convention a "can't happen" internal error, so
this is a user-reachable internal error.  The control case above proves the
custom opclass itself works.

**Why it is not tested:** the correct behaviour is a genuine design question,
not an obvious output.  PostgreSQL could support concrete-typed range
opclasses here, or reject them with a proper `ereport`; those two resolutions
have completely different expected output, and pinning either one would
prejudge the fix.  Pinning the *current* `XX000` text would enshrine a bug.
The repro above is self-contained and reproduces in seconds.

Independently found by two hunt agents (h57, h64) and then reproduced by
hand.

### D6 — Statement triggers for leftovers fire on the wrong table (inheritance)

Since `7d13b03`, leftovers are correctly re-inserted into the *child* table
they came from, but `fireBSTriggers()`/`fireASTriggers()` still use
`rootResultRelInfo`, so statement-level `INSERT` triggers fire on the parent:

```sql
CREATE TABLE iparent (id int, valid_at daterange, name text);
CREATE TABLE ichild () INHERITS (iparent);
INSERT INTO ichild VALUES (1, daterange('2000-01-01','2010-01-01'), 'one');
CREATE TRIGGER iparent_ai AFTER INSERT ON iparent FOR EACH STATEMENT
  EXECUTE FUNCTION stmt_trg();
CREATE TRIGGER ichild_ai  AFTER INSERT ON ichild  FOR EACH STATEMENT
  EXECUTE FUNCTION stmt_trg();

INSERT INTO ichild VALUES (2, daterange('2000-01-01','2001-01-01'), 'ctl');
NOTICE:  statement AFTER INSERT fired on ichild        -- control: correct

UPDATE iparent FOR PORTION OF valid_at FROM '2002-01-01' TO '2003-01-01'
  SET name = 'x';
NOTICE:  statement AFTER INSERT fired on iparent
NOTICE:  statement AFTER INSERT fired on iparent       -- wrong table, twice
```

The rows demonstrably land in `ichild` (`SELECT tableoid::regclass ...`
confirms all three resulting rows are `ichild` rows), so a per-table audit
trigger on `ichild` silently misses the leftover inserts while a trigger on
`iparent`, which receives no rows at all, fires spuriously.

**Why it is not tested:** correct behaviour is under-determined.  It is not
obvious whether one statement-level firing per leftover is intended at all
(the current design fires them once per leftover, which is itself arguably
wrong — see D7), so a test would have to pin both the *table* and the
*number* of firings, and only the table is clearly wrong.  Fixing the target
relation without settling the firing count would leave the test asserting
half-specified behaviour.

### D7 — `AFTER` row triggers on leftovers fire mid-statement

`ExecForPortionOfLeftovers()` wraps every leftover insert in its own
`AfterTriggerBeginQuery()`/`AfterTriggerEndQuery()` pair
(`nodeModifyTable.c:1616-1621`) so that each leftover gets its own transition
table.  A side effect is that `AFTER ... FOR EACH ROW` triggers on those
inserts run *while the outer statement is still scanning*, which breaks the
guarantee that `AFTER` row triggers observe the statement's final state.
The visible consequence is a hard error that the user cannot act on:

```sql
CREATE FUNCTION sib() RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
  IF pg_trigger_depth() > 1 THEN RETURN NULL; END IF;
  EXECUTE format('UPDATE %I SET touched = touched + 1 WHERE id <> $1',
                 TG_TABLE_NAME) USING NEW.id;
  RETURN NULL;
END $$;

CREATE TRIGGER ai_ai AFTER INSERT ON ai FOR EACH ROW EXECUTE FUNCTION sib();
UPDATE ai FOR PORTION OF valid_at FROM '2002-01-01' TO '2003-01-01'
  SET name = name || '!';
ERROR:  tuple to be updated was already modified by an operation triggered by the current command
HINT:  Consider using an AFTER trigger instead of a BEFORE trigger to propagate changes to other rows.
```

The `HINT` is impossible to follow: the trigger already *is* an `AFTER`
trigger.  The same trigger body attached as an `AFTER UPDATE` trigger on a
plain multi-row `UPDATE` — the exact pattern the `HINT` recommends — succeeds:

```sql
UPDATE ai2 SET name = name || '!';
UPDATE 2
 id | name | touched
----+------+---------
  1 | one! |       1
  2 | two! |       1
```

A milder manifestation of the same root cause: an `AFTER INSERT` trigger on a
leftover can observe other rows targeted by the same statement still in their
pre-image state.

**Why it is not tested:** the fix is a design decision with no obvious
expected output — deferring the leftover triggers to end of statement would
change the per-leftover transition-table semantics that `8e72d91` and its
follow-ups deliberately introduced, so the "correct" result is exactly what
is in dispute.  Pinning today's error would enshrine the bug.

### D8 — A `BEFORE INSERT` trigger returning `NULL` silently discards leftovers

Leftovers are inserted through the normal `ExecInsert()` path, so a
`BEFORE INSERT ... FOR EACH ROW` trigger that returns `NULL` suppresses them.
Nothing reports this, and the command tag still counts the row:

```sql
CREATE TRIGGER skip BEFORE INSERT ON t FOR EACH ROW
  EXECUTE FUNCTION return_null();

UPDATE t FOR PORTION OF valid_at FROM '2002-01-01' TO '2003-01-01' SET name = 'x';
UPDATE 1
-- only the truncated [2002-01-01,2003-01-01) row survives; both leftovers,
-- i.e. all history outside the updated portion, are gone

DELETE FROM t FOR PORTION OF valid_at FROM '2004-01-01' TO '2005-01-01';
DELETE 1
-- the entire row is destroyed, not just the requested portion
```

So a trigger that a user installed to veto *new* rows also silently deletes
*existing* history.

**Why it is not tested:** this is arguably by design.  The project has
already chosen, in commit `9170c8b`, to capture rather than reject a closely
analogous nonsensical outcome (a `BEFORE UPDATE` trigger that rewrites
`NEW.valid_at` during `FOR PORTION OF`), noting that DB2 rejects it while
MariaDB matches PostgreSQL.  Adding a test that asserts the opposite
disposition would prejudge that call.  It is, however, undocumented — neither
reference page mentions that a `BEFORE INSERT` trigger can suppress leftovers
— and it is not covered anywhere in `for_portion_of.sql`, which tests only a
tuple-*modifying* `BEFORE INSERT` trigger.

### D9 — Bounds are evaluated four times per row, at three independent sites

`transformForPortionOfClause()` deep-copies the bound expressions into
separate evaluation sites (the row-selection qual, the `SET`-list truncation
expression, and the executor's leftover computation).  For a single row and a
single statement, each bound is evaluated four times:

```sql
UPDATE v FOR PORTION OF valid_at FROM nb('2002-01-01') TO nb('2003-01-01')
  SET name = 'x';
NOTICE:  bound evaluated -> 2002-01-01
NOTICE:  bound evaluated -> 2003-01-01
NOTICE:  bound evaluated -> 2002-01-01
NOTICE:  bound evaluated -> 2003-01-01
NOTICE:  bound evaluated -> 2002-01-01
NOTICE:  bound evaluated -> 2003-01-01
NOTICE:  bound evaluated -> 2002-01-01
NOTICE:  bound evaluated -> 2003-01-01
```

Only `VOLATILE` functions are rejected, so a `STABLE`-labelled function whose
result is not actually stable yields *different* bounds at different sites
within one statement, producing overlapping history:

```sql
UPDATE v2 FOR PORTION OF valid_at FROM '2002-01-01' TO drift() SET name = 'x';
 id |        valid_at         | name
----+-------------------------+------
  1 | [2000-01-01,2002-01-01) | one
  1 | [2002-01-01,2003-01-05) | x       -- updated portion ends 01-05
  1 | [2003-01-03,2010-01-01) | one     -- leftover starts 01-03: overlap
```

On a table carrying a temporal primary key the overlap is caught
(`conflicting key value violates exclusion constraint`) and the statement
rolls back, so the invariant holds where one is declared; on an unconstrained
history table the corruption is silent.  `DELETE` can likewise leave a gap.

**Why it is not tested:** triggering the divergence requires a function whose
declared volatility is a lie, and PostgreSQL's documented position is that
mislabelling volatility yields undefined results — so a committer could
reasonably decline to call the wrong-results half a bug.  The evaluation
*count* is the objectively surprising part, but a test asserting "two
evaluations, not eight" would be pinning an implementation detail that
planner changes could legitimately alter.

### D10 — Plain `EXPLAIN` evaluates the `FOR PORTION OF` target expression

Same root cause as D3/D4.  With a `STABLE` function in the target, plain
`EXPLAIN` runs it:

```sql
EXPLAIN (COSTS OFF) UPDATE t FOR PORTION OF valid_at (noisy()) SET name = 'q';
NOTICE:  called
NOTICE:  called
```

A function in the target of an `EXPLAIN`ed statement can therefore raise
errors, consume resources, or emit messages.  It can even reject the
`EXPLAIN` outright: if the function body contains DML, `EXPLAIN` fails with
`INSERT is not allowed in a non-volatile function`.

**Why it is not tested:** exactly one of the two calls above is the defect.
The planner legitimately evaluates stable functions in a qual for selectivity
estimation, and the target expression is also copied into the `&&` qual, so
even correct behaviour emits one `NOTICE`.  A regression test would have to
assert "one notice instead of two", which is fragile and would silently stop
testing anything if planner estimation changed.  D4 exercises the same code
path deterministically and is tested instead.

### D11 — `WITH CHECK OPTION` blames the leftover, not the user's row

Leftovers are checked against the view's `WITH CHECK OPTION` before the row
the user's `SET` produced, so the `DETAIL` names a row the user never wrote,
showing its *pre-update* column values:

```sql
CREATE VIEW wv AS SELECT * FROM wt WHERE valid_at @> '2005-01-01'::date
  WITH CHECK OPTION;
UPDATE wv FOR PORTION OF valid_at FROM '2002-01-01' TO '2003-01-01'
  SET name = 'changed';
ERROR:  new row violates check option for view "wv"
DETAIL:  Failing row contains (1, [2000-01-01,2002-01-01), keepme).
```

The user's statement produced `(1, [2002-01-01,2003-01-01), changed)`, which
also violates the check option; the reported row is the untouched leftover.

**Why it is not tested:** the statement is correctly rejected and rolled
back, so only the error attribution is wrong; which of several violating rows
gets reported is not clearly specified behaviour.

### D12 — `NULL` bounds deparse as `NULL::unknown`

```sql
CREATE RULE r AS ON DELETE TO src DO INSTEAD
  DELETE FROM t FOR PORTION OF valid_at FROM NULL TO '2001-01-01';

SELECT definition FROM pg_rules WHERE rulename = 'r';
 ... DELETE FROM t FOR PORTION OF valid_at FROM NULL::unknown TO '2001-01-01'
```

`FOR PORTION OF` retains the *uncoerced* bound expressions for deparsing, so
an untyped `NULL` literal is rendered with a cast to the `unknown`
pseudo-type.  This reaches users through `pg_dump` and `\d+` output for any
rule whose action uses an unbounded `FOR PORTION OF`.

**Why it is not tested:** the output is merely ugly, not wrong — it re-parses
and restores correctly, so no user-visible failure results.  In addition, any
plausible fix for D2 changes how these retained bound expressions are stored,
which would change this rendering as a side effect; pinning the current
spelling in an expected file would be counterproductive.

### D13 — `UPDATE`/`DELETE` reference pages omit `FOR PORTION OF` privileges

`doc/src/sgml/ref/update.sgml` states:

> You must have the `UPDATE` privilege on the table, or at least on the
> column(s) that are listed to be updated.

`FOR PORTION OF` silently adds the range column to the set of updated
columns (commit `7ac030d`), but the range column is not "listed to be
updated" by the user, so the requirement is undocumented:

```sql
GRANT SELECT (id, valid_at, name), UPDATE (name) ON t TO alice;
-- as alice:
UPDATE t FOR PORTION OF valid_at FROM '2002-01-01' TO '2003-01-01' SET name = 'x';
ERROR:  permission denied for table t
```

`doc/src/sgml/ref/delete.sgml` has the mirror-image gap: it requires `SELECT`
only "for any table in the `USING` clause or whose values are read in the
condition", but `DELETE ... FOR PORTION OF` also requires `SELECT` on the
range column.

Both pages *do* document the more surprising direction (that the leftover
inserts need no `INSERT` privilege), which is correct — that claim was
verified.

**Why it is not tested:** the implemented behaviour is correct and already
has regression coverage; only the documentation is incomplete, and
documentation gaps cannot be expressed as a regression test.

### D14 — Misleading error through a view exposing the range column twice

```sql
CREATE VIEW v AS SELECT id, valid_at AS a, valid_at AS b, name FROM t;
UPDATE v FOR PORTION OF a FROM '2002-01-01' TO '2003-01-01'
  SET b = daterange('1990-01-01','1991-01-01');
ERROR:  multiple assignments to same column "valid_at"
```

The user assigned exactly one column, so the message is confusing.  The check
that would give the right message —
`cannot update column "..." because it is used in FOR PORTION OF` — is applied
in `transformUpdateTargetList()` against the *view's* column names, so it does
not notice that `b` and the `FOR PORTION OF` column `a` are the same base
column; the collision is caught later, by the generic rewriter check.

**Why it is not tested:** the outcome is safe (the statement is rejected, no
data is corrupted) and the message wording is the only problem, so this is a
polish item rather than a defect worth pinning in an expected file.

---

## Root causes

**D1** — `RewriteQuery()` (`src/backend/rewrite/rewriteHandler.c`) deliberately
defers attaching the `FOR PORTION OF` qual and range target list when the
result relation is a view:

```c
if (rt_entry_relation->rd_rel->relkind != RELKIND_VIEW)
{
    AddQual(parsetree, parsetree->forPortionOf->overlapsExpr);
    ...
}
```

The comment explains that this avoids adding the same qual twice "on the
recursion" — i.e. it assumes rewriting will recurse onto the same `Query`
once the view is expanded.  A `DO INSTEAD` rule breaks that assumption: it
discards the `Query` carrying `forPortionOf` and substitutes the rule action,
which has no `forPortionOf` of its own.  The clause therefore disappears
without ever being applied.  The neighbouring guard for `INSTEAD OF`
triggers (`views with INSTEAD OF triggers do not support FOR PORTION OF`,
commit `dfce19c`) covers only the trigger form of the same hazard.

Note that `DO INSTEAD` rules on a *table* are unaffected in the same way and
are already covered by existing tests, because for non-views the qual is
attached before rules fire.

**D2, D12** — `transformForPortionOfClause()` (`src/backend/parser/analyze.c`)
builds the range constructor from *copies* of the bound expressions:

```c
args = list_make2(copyObject(result->targetFrom),
                  copyObject(result->targetTo));
...
make_fn_arguments(pstate, args, actual_arg_types, declared_arg_types);
```

`make_fn_arguments()` resolves an untyped `PARAM_EXTERN` through
`p_coerce_param_hook`, which records the resolved type *and* fixes the `Param`
node in place — but only for the copy.  The originals kept in
`ForPortionOfExpr.targetFrom` / `.targetTo` (retained for deparsing) keep
`paramtype == UNKNOWNOID`, so `check_parameter_resolution_walker()` sees a
`Param` whose type disagrees with the resolved parameter list and raises
`could not determine data type of parameter $n`.  Dropping the two
`copyObject()` calls makes D2 disappear; it also leaves every existing
expected-output file unchanged.

**D3, D4, D10** — `ExecInitModifyTable()`
(`src/backend/executor/nodeModifyTable.c`) evaluates the target
unconditionally at executor start-up:

```c
if (node->forPortionOf)
{
    ...
    exprState = ExecPrepareExpr((Expr *) forPortionOf->targetRange, estate);
    targetRange = ExecEvalExpr(exprState, econtext, &isNull);
    if (isNull)
        ereport(ERROR, ... "FOR PORTION OF target must not be null" ...);
```

There is no `EXEC_FLAG_EXPLAIN_ONLY` guard, even though the same function
already skips `ExecSetupTransitionCaptureState()` in explain-only mode a few
hundred lines earlier.  Under `EXPLAIN` the expression is evaluated (D10), a
`Param` has no value yet under `GENERIC_PLAN` (D3), and the run-time null
check fires (D4).  Adding `&& !(eflags & EXEC_FLAG_EXPLAIN_ONLY)` to the
condition fixes all three.

**D7** — `ExecForPortionOfLeftovers()` opens and closes a nested
after-trigger query context per leftover (`nodeModifyTable.c:1616-1621`).

All three candidate fixes were applied temporarily, and only temporarily, to
confirm the diagnoses and to generate the expected output for the new tests;
**the committed tree contains no source changes**, only new tests.

---

## Test status

The new tests encode *correct* behaviour, so they fail against `0b776de`;
the failure diff is the defect report.  Run them with:

```
meson setup build -Dcassert=true -Dtap_tests=enabled && ninja -C build install
build/src/test/regress/pg_regress --bindir=<prefix>/bin \
    --inputdir=src/test/regress --expecteddir=src/test/regress \
    --dlpath=build/src/test/regress --temp-instance=/tmp/ti for_portion_of
```

On master this produces a 190-line, 5-hunk diff containing 11 unexpected
`ERROR` lines (D2/D3/D4) and 3 missing `ERROR` lines (D1), plus the wrong
row contents D1 leaves behind.  All hunks fall inside the two new blocks,
which begin at line 2796 of the expected file; the 2795 lines of pre-existing
expected output are untouched.  With the three candidate fixes applied the
test passes.

---

## Rejected candidate findings

Reported by hunt agents, but withdrawn after hands-on verification:

* *A `BEFORE UPDATE` trigger that rewrites `NEW.valid_at` produces senseless
  results.*  Already covered by an existing test
  (`for_portion_of.sql:1228-1270`, commit `9170c8b`), which deliberately
  captures the current behaviour.
* *`DELETE ... FOR PORTION OF` does not require `UPDATE` privilege on the
  range column, unlike `UPDATE`.*  Intentional and already enshrined in
  `src/test/regress/sql/privileges.sql`.
* *RLS `INSERT` policies on inheritance children / partition leaves are
  skipped for leftovers.*  Plain non-temporal `INSERT` behaves identically;
  a pre-existing general limitation, not caused by `8e72d91`.
* *Rule `DO ALSO` side effects are invisible in the command tag*, and
  *`RETURNING OLD`/`NEW` collide with rule `OLD`/`NEW` pseudo-relations.*
  Both reproduce without `FOR PORTION OF`.

---

## Areas examined without finding a defect

For coverage accounting, the following were probed empirically against the
cassert build and behaved correctly:

* Range algebra: ~700 combinations of row range × target bounds for
  `int4range` (inclusive/exclusive/infinite/empty/`NULL` bounds), compared
  against `range_intersect` / `multirange_minus` ground truth — no mismatch.
* Leftover count and content for range and multirange columns, including the
  documented "multiranges never need two leftovers" claim.
* `RETURNING OLD.*` / `NEW.*` for both `UPDATE` and `DELETE`; leftovers
  correctly excluded from `RETURNING` and from the command tag; row counts and
  `GET DIAGNOSTICS ROW_COUNT` across ~20 shapes.
* Transition tables (`REFERENCING OLD/NEW TABLE`) for statement- and
  row-level triggers, including partitioned and cross-partition cases.
* Partitioning: cross-partition moves, sub-partitioning, `DEFAULT`
  partitions, leftovers routed to a partition pruned from the plan, foreign
  table partitions, and prepared plans surviving detach/reattach.
* Plain inheritance leftover routing (multi-level, dropped/reordered columns,
  child-local indexes) — correct since `7d13b03`; only the statement-trigger
  target relation is wrong (D6).
* Views: auto-updatable, subset-column, renamed-column, nested (1–3 levels),
  `security_barrier`, and `WITH CHECK OPTION` (`LOCAL` and `CASCADED`);
  deparse round-trip through `pg_get_ruledef` including quoted and Unicode
  identifiers and column renames.
* Constraints: table `CHECK`, domain `CHECK`, domains over ranges and
  multiranges, temporal primary keys (`WITHOUT OVERLAPS`), temporal foreign
  keys (`PERIOD`) in both directions, `EXCLUDE` constraints, including
  deferred forms.
* Identity and `DEFAULT` columns, generated columns (`STORED` and `VIRTUAL`),
  dropped columns, `TOAST`ed values (each leftover receives its own TOAST
  value — no pointer sharing, so no reclamation hazard).
* Privileges and RLS, including the documented "leftover inserts need no
  `INSERT` privilege" behaviour and `FORCE ROW LEVEL SECURITY`.
* Bound expressions: column references, subqueries, aggregates, window
  functions, set-returning functions, `DEFAULT`, and volatile functions are
  all rejected with correct error codes and cursor positions.
* Stored `NULL` and empty ranges: the overlap qual `range && target` is ANDed
  onto the statement's `WHERE`, and yields `NULL`/`false` for such rows, so
  they are never touched and the defensive `found a NULL range in a temporal
  table` `elog` is unreachable from ordinary SQL.
* Concurrency: `READ COMMITTED` `EvalPlanQual` re-checks racing a concurrent
  range-shrinking `UPDATE`, including the trigger-driven EPQ path.
* Plan caching: generic plans over seven executions, and plan invalidation by
  `ALTER TABLE ... ALTER COLUMN TYPE` / `DROP COLUMN` underneath a prepared
  `FOR PORTION OF` statement.
* Documentation claims in `update.sgml`, `delete.sgml`, `dml.sgml`,
  `trigger.sgml` and the glossary, executed verbatim.
* Miscellaneous: data-modifying CTEs, `COPY (... FOR PORTION OF ...
  RETURNING) TO`, `debug_parallel_query = on`, `UPDATE ... FROM` with a
  self-join matching the target row twice, and `WHERE CURRENT OF` rejection.
