From 03eaf97171c4aa7564315d47bc0697cd5a730103 Mon Sep 17 00:00:00 2001
From: Greg Burd <greg@burd.me>
Date: Wed, 4 Nov 2026 13:38:52 -0500
Subject: [PATCH v2 2/2] Add property-based test harness and a few high-value
 example properties

pg_prop is a small C program that binds libhegel's stable C ABI (hegel.h):
the engine generates many inputs, checks an invariant, and on failure shrinks
to a minimal counterexample and prints a seed that replays it.  A property is
an ordinary C function that draws inputs and asserts a model, an invariant, or
a differential equality.  A TAP driver runs each area against a throwaway
server; a developer reruns one area or property by hand with
--iterations / --seed.

Rather than a broad sweep, the initial set is a few strong examples in areas
that are hard to get right and likely to be re-optimized, each with a sound
oracle:

 - binaryheap: a data-structure model test.  It drives src/common/binaryheap.c
   and a trusted reference (a sorted array) through the same random operations
   and requires they agree -- pop order sorted, first is the minimum, and an
   incrementally-built heap pops in the same order as one built with Floyd's
   bulk binaryheap_build.  The last catches a divergence between the two build
   algorithms on any input, not one hand-chosen case.

 - nbtree: a plan-results differential.  The same query answered by a forced
   index scan and by a forced sequential scan must return the same rows, over
   a randomly-shaped index and a predicate mixing equality, inequality,
   IN-lists and IS NULL with a random scan direction -- which is what exercises
   the skip-scan scan-key handling.  The oracle never computes the expected
   rows, it only requires the two paths agree, so any divergence is a bug in
   the index path by construction.

 - isolation: transaction isolation guarantees across two concurrent backends.
   Using the async send/await primitives to interleave two sessions, each
   property asserts the documented guarantee of an isolation level: under
   SERIALIZABLE the classic write-skew pair cannot both commit and a phantom
   is prevented; under REPEATABLE READ a snapshot stays stable across a
   concurrent committed update; under READ COMMITTED a new statement sees a
   concurrently committed change; and two txns locking in opposite order
   deadlock with exactly one victim.  The oracle is the documented guarantee,
   not a generated reference, so an anomaly committing or a snapshot shifting
   is a serious isolation bug by construction.  This is where MVCC is hardest
   and where the multi-session schedules a fixed isolation spec does not
   enumerate are exactly the bug surface.

prng is an in-process self-test of the generator.  Counterexamples replay
deterministically from a printed seed.  A multi-table query generator with a
wider differential oracle is left for a follow-up.

This method has already found live bugs on master; see the thread.
---
 src/test/prop/Makefile             |   40 ++
 src/test/prop/README               |  307 ++++++++
 src/test/prop/meson.build          |   51 ++
 src/test/prop/pg_prop.c            | 1039 ++++++++++++++++++++++++++++
 src/test/prop/pg_prop.h            |  381 ++++++++++
 src/test/prop/pg_prop_sql.c        |  409 +++++++++++
 src/test/prop/prop_binaryheap.c    |  165 +++++
 src/test/prop/prop_nbtree.c        |  227 ++++++
 src/test/prop/prop_prng.c          |  132 ++++
 src/test/prop/sql/prop_isolation.c |  432 ++++++++++++
 src/test/prop/t/001_prop.pl        |   93 +++
 11 files changed, 3276 insertions(+)
 create mode 100644 src/test/prop/Makefile
 create mode 100644 src/test/prop/README
 create mode 100644 src/test/prop/meson.build
 create mode 100644 src/test/prop/pg_prop.c
 create mode 100644 src/test/prop/pg_prop.h
 create mode 100644 src/test/prop/pg_prop_sql.c
 create mode 100644 src/test/prop/prop_binaryheap.c
 create mode 100644 src/test/prop/prop_nbtree.c
 create mode 100644 src/test/prop/prop_prng.c
 create mode 100644 src/test/prop/sql/prop_isolation.c
 create mode 100644 src/test/prop/t/001_prop.pl

diff --git a/src/test/prop/Makefile b/src/test/prop/Makefile
new file mode 100644
index 00000000000..3dcff0b6852
--- /dev/null
+++ b/src/test/prop/Makefile
@@ -0,0 +1,40 @@
+# src/test/prop/Makefile
+#
+# Property-based tests built on libhegel.  Only reachable when configure was
+# run with --with-hegel (which also requires --enable-tap-tests); see
+# src/test/Makefile.
+#
+# Everything links into one program, pg_prop, whose first argument selects the
+# area:  pg_prop <area> [options] [property ...].  The TAP driver runs each
+# area; a developer debugging a failure runs one area or one property by hand
+# with --iterations / --seed.  See README.
+
+PGFILEDESC = "pg_prop - property-based tests"
+PGAPPICON = win32
+
+PROGRAM = pg_prop
+OBJS = \
+	$(WIN32RES) \
+	pg_prop.o \
+	pg_prop_sql.o \
+	prop_binaryheap.o \
+	prop_nbtree.o \
+	sql/prop_isolation.o \
+	prop_prng.o
+
+PG_CPPFLAGS = -I$(libpq_srcdir) -I$(srcdir)/sql
+PG_LIBS_INTERNAL += -L$(top_builddir)/src/fe_utils -lpgfeutils $(libpq_pgport)
+PG_LIBS_INTERNAL += -lhegel_c
+
+NO_INSTALL = 1
+TAP_TESTS = 1
+
+subdir = src/test/prop
+top_builddir = ../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+
+LDFLAGS += $(foreach dir,$(LIBRARY_DIRS),-Wl,-rpath,$(dir))
+
+pg_prop$(X): | submake-libpgfeutils
+check: pg_prop$(X)
diff --git a/src/test/prop/README b/src/test/prop/README
new file mode 100644
index 00000000000..bab630c70e6
--- /dev/null
+++ b/src/test/prop/README
@@ -0,0 +1,307 @@
+src/test/prop/README
+
+Property-based tests
+====================
+
+This directory holds PostgreSQL's optional property-based tests (PBTs), built
+on the Hegel engine (libhegel; see https://hegel.dev).  A property-based test
+states a property that must hold for every input in some domain -- a
+round-trip, an invariant, an algebraic identity, or agreement between two ways
+of computing the same answer -- and the engine generates many inputs trying to
+falsify it.  When it finds a counterexample it shrinks it to a minimal failing
+input and prints a blob that replays it exactly.
+
+These tests are entirely optional and are off by default.  They are built and
+run only when all of the following hold:
+
+1. you explicitly enable them at configure time (--with-hegel for autoconf,
+   -Dprop_tests=enabled for meson);
+
+2. libhegel (its hegel.h header and libhegel_c shared library) is present and
+   reachable via --with-includes/--with-libraries (autoconf) or
+   --extra-include-dirs/--extra-lib-dirs (meson); and
+
+3. TAP tests are enabled (--enable-tap-tests / -Dtap_tests=enabled).
+
+If any piece is missing the suite is silently skipped.  If the opt-in flag is
+set but a piece is missing, configure fails with a clear message rather than
+quietly doing nothing.
+
+See DESIGN in this directory for how it all works under the covers.
+
+
+When to use a property-based test (and when not to)
+===================================================
+
+Reach for a PBT when the code has a property that should hold across a large
+input space:
+
+* Round-trips: parse(format(x)) = x, decompress(compress(x)) = x.
+
+* Idempotence: f(f(x)) = f(x) -- normalization, case folding, canonical path
+  cleanup.
+
+* Invariants and contracts: a verifier stops on a character boundary; a
+  function's output stays within documented bounds; a comparator is a total
+  order.
+
+* Differential oracles: two ways of computing the same answer must agree -- an
+  index scan versus a sequential scan, a query with an optimization on versus
+  off, a partitioned table versus an unpartitioned one with the same rows.
+
+* No-crash and robustness: a parser handles arbitrary bytes without crashing.
+
+Prefer an ordinary regression or TAP test when:
+
+* The test checks exact output.  "render(x) = <html>..." depends on a specific
+  format; there is no general property, just a fixed expectation.
+
+* The test checks a specific error message.  PBTs are good at "an error is
+  raised", poor at "the error says exactly this".  Test the error class or
+  SQLSTATE, never the wording (it is reworded and translated between releases).
+
+* The output is deliberately nondeterministic: random(), unseeded
+  gen_random_uuid(), clock_timestamp(), pg_stat_*, timing, memory figures.
+  There is no stable oracle.
+
+* The target is plan shape or cost, not plan results.  EXPLAIN output is
+  cost-model dependent and is intended to change across releases and with
+  statistics.  (Plan results are a prime PBT target -- see the query_* areas;
+  plan shape is not.)
+
+* The only oracle is a second copy of the same algorithm.  If the test
+  reimplements the function to check the function, it finds bugs in the test,
+  not the server.  This is the single most common way a property goes wrong;
+  prefer a round-trip or a differential oracle (two independent ways that must
+  agree) over an invented formula.
+
+* The target is floating-point textual edge output (extra_float_digits,
+  last-ULP rendering): that is platform and libc behavior, not PostgreSQL's.
+
+* The target is performance or resource use.  PBT asserts correctness, not
+  speed; that is a benchmark.
+
+* Setup dominates.  If each case needs elaborate fixtures, the generator is
+  mostly scaffolding and a few hand-written cases are clearer.
+
+* No real property is apparent.  Don't force a tautology ("x = x"); a good
+  unit test beats a contrived PBT.
+
+The rule of thumb: a PBT belongs only where a disagreement is unambiguously a
+bug.  That is why the SQL-oracle (Tier B) areas lean on differential oracles
+and round-trips, not invented formulas.
+
+A PBT complements, rather than replaces, the existing regression and TAP
+suites.  Its value is exercising the combinations and boundary inputs a human
+does not think to write by hand -- the three-byte "0.0" fed one byte at a time,
+the two-partition prefix-equality query -- not raising line coverage.  Measured,
+the suite adds only about 0.4% line coverage over the regression and isolation
+suites, and it does not execute shared lines more often than they do; its
+measurable gain is reaching different lines (for example, 242 lines of
+src/common/jsonapi.c that the json and jsonb regression tests never reach) plus
+the standing differential net over the planner and executor.  See DESIGN for
+the measurement method and figures.
+
+
+Running
+=======
+
+The tests are organized into areas, each targeting one piece of code.  There
+are two kinds.  In-process (Tier A) areas link the code under test directly
+into the test program and need no server.  SQL-oracle (Tier B) areas drive SQL
+against a running cluster and compare the answer to an oracle.  All areas are
+compiled into one program, pg_prop, whose first argument selects the area:
+
+    pg_prop <area> [options] [property ...]
+
+Through the test harness:
+
+    # meson: run the whole prop suite (both TAP drivers)
+    meson test --suite prop
+
+    # meson: one driver (001 = in-process areas, 002 = SQL-oracle areas)
+    meson test prop/001_prop
+    meson test prop/002_sql_props
+
+    # autoconf
+    make -C src/test/prop check
+
+Directly, for development and debugging.  An in-process area needs no server:
+
+    pg_prop --list                               # list every area/property
+    pg_prop jsonapi --list                        # any area filters the list
+    pg_prop jsonapi                               # all jsonapi properties
+    pg_prop jsonapi chunking_agrees_with_oneshot  # one property
+    pg_prop jsonapi chunking_agrees_with_oneshot -n 100000 -s 42
+
+A SQL-oracle area reads PGHOST/PGPORT/PGDATABASE from the environment, so point
+it at any cluster:
+
+    PGHOST=/tmp PGPORT=5432 PGDATABASE=postgres \
+        pg_prop partprune pruning_preserves_rows -n 100000 -s 42
+
+Controlling iterations and the seed:
+
+* -n, --iterations N -- number of test cases per property (default 100, kept
+  low so check-world stays cheap; raise it when hunting a bug).
+
+* -s, --seed N -- fix the RNG seed for a reproducible run.  When you do not give
+  one, pg_prop picks a seed and prints it ("# seed: NNN"), so a failure seen in
+  CI can be replayed with -s NNN.
+
+The same three controls are available as the environment variables
+PG_PROP_ITERATIONS, PG_PROP_SEED, and PG_PROP_TESTS (a space-separated list of
+property names), which the TAP drivers forward -- so you can raise the
+iteration count of a meson test run without editing anything:
+
+    PG_PROP_ITERATIONS=10000 PG_PROP_SEED=42 meson test --suite prop
+    PG_PROP_TESTS="pruning_preserves_rows" meson test prop/002_sql_props
+
+Two further knobs tune the deep multi-table generator:
+
+* PG_PROP_GEN_PRNG -- control deep-generator reproducibility from a seed.
+  The multi-table builder makes hundreds of data-dependent draws per case,
+  a sequence the engine did not replay reliably (so a deep counterexample
+  could not be reproduced).  When active, the builder takes one engine draw
+  per case and drives all structural choices from a deterministic PRNG seeded
+  by it, so the case replays exactly; the cost is coarser shrinking of deep
+  structure.  DEFAULT: on whenever a deep profile is selected (PG_PROP_DEEP
+  set), because for a hunt/vet run replaying a hit matters more than the
+  engine's structural shrinking (which it cannot do for a deep query anyway);
+  off for the default single-table profile, where engine shrinking of the
+  short draw sequence works well.  PG_PROP_GEN_PRNG=1 forces it on,
+  PG_PROP_GEN_PRNG=0 forces it off, overriding the profile default.
+
+* PG_PROP_GEN_BIAS=tag,tag,... -- bias the deep generator toward the SQL
+  surface a patch under review touches, when vetting a commitfest entry.
+  scripts/cf-features.py scans the patch diff and prints the tag list; feed
+  it in so the generator weights joins / setops / subquery / nulls / ... as
+  the patch does.  Biasing only reweights -- every shape is still reachable
+  -- so a biased run focuses without losing neighbouring-bug coverage.
+
+    PG_PROP_GEN_BIAS=$(scripts/cf-features.py /tmp/cfpatch/6824/*.patch) \
+      PG_PROP_GEN_PRNG=1 PG_PROP_DEEP=fast \
+      pg_prop query_diff -n 500 diff_deep_join
+
+
+Active and frozen areas
+=======================
+
+The SQL-oracle driver runs the active areas by default: the query generator
+and its oracles (query_*), the stateful, concurrency and access-method areas,
+and the commitfest areas (cf_*).  The per-type scalar areas (numeric, text,
+jsonb, ...) are frozen: they compile and run with PG_PROP_FROZEN=1, but are
+not maintained.  In the 2026 evaluation they found no server bug and most of
+their findings were unsound oracles.  A frozen area that breaks after an
+upstream change may be deleted rather than fixed.
+
+New work goes into the generator (sql/query_gen.c) rather than new oracles.
+A new query shape is checked by every existing oracle at once; a new oracle
+checks only the shapes the generator already makes.
+
+The single-table generator (qg_build_table) and the oracles over it are
+reproducible: a counterexample replays from its seed.  The deep multi-table
+generator (qg_build_db / qg_build_query, used by the query_deep, query_multi
+and diff_deep_* properties under PG_PROP_DEEP) was once seen to be
+non-reproducible for a fixed seed on one environment (finding 041), but that
+no longer reproduces on the current libhegel in any profile, same-seed or
+across-seed.  For determinism that does not depend on the engine's
+draw-replay behaviour at all, set PG_PROP_GEN_PRNG=1: the deep case is then
+reproducible by construction (one engine draw drives a local PRNG).  A deep
+disagreement is still best reproduced by hand before it is believed, but a
+deep run is no longer disqualified as a regression gate on that ground.
+
+
+Commitfest properties and retirement
+====================================
+
+A cf_* property encodes one open commitfest entry: it fails on master and
+passes with that entry's patch.  It earns its keep while the patch is under
+review.  Once the fix is committed, the patch's own regression test covers
+the case, and the property is retired: delete it (or move its invariant into
+a generator shape, if it generalises).  scripts/cf-retire.sh lists the
+properties whose CF entry is committed, withdrawn or rejected.
+
+
+Debugging a failure
+===================
+
+1. Read the counterexample.  On failure pg_prop prints a one-line "falsified:"
+   message that includes the minimal inputs Hegel shrank to, plus a "rerun
+   with" blob.  The TAP drivers echo this as "#" diagnostics.
+
+2. Reproduce it deterministically.  Re-run the single property with the seed
+   from the run's "# seed:" line:
+
+       pg_prop <area> <property> -s <seed> -n <iterations>
+
+   (For a SQL area, set PGHOST/PGPORT first.)  You can also raise verbosity with
+   -v to see every drawn value, not just the final counterexample.
+
+3. Decide what the failure means.  There are three possibilities:
+
+   * A real bug in the code under test -- the code violated its own contract.
+     Capture the reproducer.
+
+   * An unsound property -- the test asserted something the code never promised
+     (for example, that a string verifier and a character verifier agree on a
+     NUL byte -- they deliberately do not).  Fix the property.
+
+   * An over-broad generator -- the failing input is genuinely outside the
+     function's domain.  Only then narrow the generator, and say why in a
+     comment.
+
+4. Step through it.  The reproducing command is an ordinary process; run it
+   under gdb or lldb with a breakpoint in the code under test.  Because the seed
+   is fixed, the same inputs are generated every time.
+
+
+Adding a property
+=================
+
+To add a property to an existing area, add a PG_PROP(name) { ... } function to
+that area's prop_<area>.c.  It is registered automatically.
+
+To add a new area, create prop_<area>.c beginning with
+
+    #define PG_PROP_AREA "<area>"
+
+before including pg_prop.h, then add the file to the source list in meson.build,
+the OBJS list in Makefile, and the area list in the matching TAP driver
+(t/001_prop.pl for in-process areas, t/002_sql_props.pl for SQL-oracle areas).
+
+See pg_prop.h for the full generator and assertion API, and DESIGN for the
+rationale and internals.
+
+
+Extensions
+==========
+
+An extension can take part in the suite in two ways, and may do either or both.
+
+(a) Add its own area.  This is just "add a new area" above with the extension's
+name: a prop_<ext>.c that defines PG_PROP_AREA and uses PG_PROP(), whose connect
+helper runs CREATE EXTENSION IF NOT EXISTS <ext>.  Every contrib area already in
+this tree (hstore, ltree, citext, cube, seg, intarray, pg_trgm, ...) is exactly
+this shape.  An out-of-tree extension ships the file and arranges for it to be
+compiled into the pg_prop binary.
+
+(b) Augment an existing area through an extension point.  A core area may
+publish a named point and call pg_prop_run_hooks() there; an extension registers
+a contributor with PG_PROP_HOOK("point", fn).  The SQL query generator
+(sql/query_gen.h) publishes three:
+
+    query_gen.setup      run DDL (CREATE EXTENSION) before each generated table;
+                         ctx is the live connection.
+    query_gen.column     add a typed column to the generated table; ctx is the
+                         qg_schema being assembled.
+    query_gen.predicate  emit a predicate leaf over the extension's column; ctx
+                         is a qg_pred_ctx (schema + buffer).
+
+Registering those three teaches the generator about the extension's type, after
+which every query-level oracle -- TLP, NoREC, cardinality, PQS, plan-advice --
+generates tables and queries that use the type and checks all of its invariants
+over it, with no new oracle code.  sql/prop_query_ext_citext.c is a complete
+worked example (it also adds one citext-specific area, to show both styles in
+one file).  A core area makes itself extensible simply by publishing a point and
+documenting the ctx type its contributors receive.
diff --git a/src/test/prop/meson.build b/src/test/prop/meson.build
new file mode 100644
index 00000000000..55732dacfb3
--- /dev/null
+++ b/src/test/prop/meson.build
@@ -0,0 +1,51 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+# Property-based test harness, built only when libhegel is available
+# (-Dprop_tests=enabled; see the top-level meson.build).  All areas link into
+# one binary, pg_prop, whose first argument selects the area:
+#   pg_prop <area> [options] [property ...].
+# t/001_prop.pl runs each area against a throwaway server it starts.
+#
+# The initial set is a few strong examples in high-value areas, each with a
+# sound oracle (a model, an invariant, or a differential check):
+#   binaryheap  -- a data-structure model test on src/common/binaryheap.c
+#   nbtree      -- an index-scan vs sequential-scan plan-results differential
+#   isolation   -- transaction isolation guarantees across concurrent backends
+#                  (write-skew, snapshot stability, phantoms, deadlock victim)
+# prng is an in-process self-test of the generator.  Further areas are added
+# here as they land.
+
+if not prop_tests_enabled
+  subdir_done()
+endif
+
+prop_area_sources = files(
+  'prop_binaryheap.c',
+  'prop_nbtree.c',
+  'sql/prop_isolation.c',
+  'prop_prng.c',
+)
+
+pg_prop = executable('pg_prop',
+  files('pg_prop.c', 'pg_prop_sql.c') + prop_area_sources,
+  include_directories: include_directories('.', 'sql'),
+  dependencies: [frontend_code, libpq, libhegel],
+  kwargs: default_bin_args + {
+    'install': false,
+  },
+)
+testprep_targets += pg_prop
+
+if prop_tests_tap
+  tests += {
+    'name': 'prop',
+    'sd': meson.current_source_dir(),
+    'bd': meson.current_build_dir(),
+    'tap': {
+      'tests': [
+        't/001_prop.pl',
+      ],
+      'deps': [pg_prop],
+    },
+  }
+endif
diff --git a/src/test/prop/pg_prop.c b/src/test/prop/pg_prop.c
new file mode 100644
index 00000000000..3546bb8bedd
--- /dev/null
+++ b/src/test/prop/pg_prop.c
@@ -0,0 +1,1039 @@
+/*-------------------------------------------------------------------------
+ *
+ * pg_prop.c
+ *	  Property-based testing harness: libhegel driver, generators, CLI, TAP.
+ *
+ * See pg_prop.h for the API contract.  This file is linked into every test
+ * binary under src/test/prop and provides main().
+ *
+ * The CLI shape is:
+ *
+ *		pg_prop_<area> [options] [PROPERTY ...]
+ *
+ *	--list                list registered properties and exit
+ *	-n, --iterations N    number of test cases per property (default 100)
+ *	-s, --seed N          fixed RNG seed (default: random, printed as a note)
+ *	-v, --verbose         raise engine verbosity
+ *	-q, --quiet           lower engine verbosity
+ *	--no-tap              plain output instead of TAP (for interactive use)
+ *	-h, --help            usage
+ *
+ * With no PROPERTY arguments every registered property runs.  Environment
+ * variables PG_PROP_ITERATIONS, PG_PROP_SEED and PG_PROP_TESTS supply the same
+ * three controls (so they can be driven through the TAP wrapper and meson);
+ * explicit command-line options win over the environment.
+ *
+ * Output is TAP by default so meson/prove consume it directly: one "ok"/"not
+ * ok" line per property, the counterexample and replay blob as "#" diagnostic
+ * lines under a failure.
+ *
+ * Copyright (c) 2026, PostgreSQL Global Development Group
+ *
+ * IDENTIFICATION
+ *	  src/test/prop/pg_prop.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres_fe.h"
+
+#include <math.h>
+#include <setjmp.h>
+#include <signal.h>
+#include <stdarg.h>
+#include <sys/wait.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "common/logging.h"
+#include "getopt_long.h"
+#include "lib/stringinfo.h"
+#include "pg_prop.h"
+
+/*
+ * libhegel's C ABI.  Installed under a prefix given to configure via
+ * --with-includes / extra_include_dirs; the header has no PostgreSQL
+ * dependencies.
+ */
+#include "hegel.h"
+
+/* ---- registry -------------------------------------------------------- */
+
+typedef struct PropEntry
+{
+	const char *area;
+	const char *name;
+	pg_prop_fn	fn;
+	void	   *arg;
+} PropEntry;
+
+#define MAX_PROPS 4096
+static PropEntry props[MAX_PROPS];
+static int	nprops = 0;
+
+void
+pg_prop_register(const char *area, const char *name, pg_prop_fn fn, void *arg)
+{
+	if (nprops >= MAX_PROPS)
+	{
+		fprintf(stderr, "pg_prop: too many properties (max %d)\n", MAX_PROPS);
+		exit(2);
+	}
+	props[nprops].area = area;
+	props[nprops].name = name;
+	props[nprops].fn = fn;
+	props[nprops].arg = arg;
+	nprops++;
+}
+
+/* ---- extension-point (hook) registry --------------------------------- *
+ *
+ * An extension that wants to augment an existing area -- rather than add a
+ * whole new area of its own -- registers a hook against a named extension
+ * point that the area publishes.  The area calls pg_prop_run_hooks() at the
+ * point, and every registered contributor runs in registration order with the
+ * area's context pointer.  This is how, for example, a contrib type can teach
+ * the SQL query generator to emit predicates over its own columns, so the
+ * existing TLP/NoREC/cardinality/PQS oracles then cover that type for free.
+ *
+ * Hooks are a flat list keyed by point name; areas and extensions agree on
+ * the point name and the concrete type behind the void *ctx out of band (in
+ * the point's documentation), exactly as a C callback contract always does.
+ */
+typedef struct HookEntry
+{
+	const char *point;
+	pg_prop_hook_fn fn;
+} HookEntry;
+
+#define MAX_HOOKS 1024
+static HookEntry hooks[MAX_HOOKS];
+static int	nhooks = 0;
+
+void
+pg_prop_register_hook(const char *point, pg_prop_hook_fn fn)
+{
+	if (nhooks >= MAX_HOOKS)
+	{
+		fprintf(stderr, "pg_prop: too many hooks (max %d)\n", MAX_HOOKS);
+		exit(2);
+	}
+	hooks[nhooks].point = point;
+	hooks[nhooks].fn = fn;
+	nhooks++;
+}
+
+int
+pg_prop_hooks_count(const char *point)
+{
+	int			n = 0;
+
+	for (int i = 0; i < nhooks; i++)
+		if (strcmp(hooks[i].point, point) == 0)
+			n++;
+	return n;
+}
+
+void
+pg_prop_run_hooks(pg_prop_tc *tc, const char *point, void *ctx)
+{
+	for (int i = 0; i < nhooks; i++)
+		if (strcmp(hooks[i].point, point) == 0)
+			hooks[i].fn(tc, point, ctx);
+}
+
+/*
+ * Run the Nth hook registered against "point" (0-based over just that point's
+ * contributors), or do nothing if n is out of range.  Lets an area pick one
+ * contributor at random -- e.g. the query generator choosing one extension
+ * type for a column -- rather than running all of them.
+ */
+void
+pg_prop_run_hook_n(pg_prop_tc *tc, const char *point, int n, void *ctx)
+{
+	int			seen = 0;
+
+	for (int i = 0; i < nhooks; i++)
+		if (strcmp(hooks[i].point, point) == 0)
+		{
+			if (seen == n)
+			{
+				hooks[i].fn(tc, point, ctx);
+				return;
+			}
+			seen++;
+		}
+}
+
+/* ---- per-case handle ------------------------------------------------- */
+
+/*
+ * One pg_prop_tc wraps the libhegel context and the current test case.  A
+ * failure is reported by longjmp back to the driver, which marks the case
+ * INTERESTING; the engine then shrinks and replays.  We use setjmp rather
+ * than returning a status so pg_prop_fail can be called from arbitrarily deep
+ * in a property body (and so it can be pg_attribute_noreturn, which keeps the
+ * compiler from warning about missing returns after it).
+ */
+struct pg_prop_tc
+{
+	hegel_context_t *ctx;
+	hegel_test_case_t *tc;
+	StringInfoData failmsg;		/* set when the body called pg_prop_fail */
+	bool		failed;
+	sigjmp_buf	jmp;			/* longjmp target: body -> driver */
+	const char *origin;			/* stable failure origin for this property */
+};
+
+/* ---- engine error helper --------------------------------------------- */
+
+static void
+check_rc(hegel_context_t *ctx, hegel_result_t rc, const char *what)
+{
+	/*
+	 * HEGEL_E_STOP_TEST and HEGEL_E_ASSUME are not errors: they are the
+	 * engine telling the body to stop (budget exhausted) or that the case was
+	 * assumed away.  Both are handled by the callers that can see them; any
+	 * other negative code is a real engine fault and is fatal.
+	 */
+	if (rc == HEGEL_OK || rc == HEGEL_E_STOP_TEST || rc == HEGEL_E_ASSUME)
+		return;
+	fprintf(stderr, "pg_prop: libhegel %s failed (%d): %s\n",
+			what, (int) rc, hegel_context_last_error(ctx));
+	exit(2);
+}
+
+/*
+ * A draw that returns HEGEL_E_STOP_TEST means the engine wants the body to
+ * stop immediately (it ran out of choice budget for this case).  We model
+ * that as a failure-free longjmp out of the body: the driver then marks the
+ * case OVERRUN.  pg_prop_fail reuses the same jump with failed=true.
+ */
+static pg_prop_tc *current_tc;	/* for the stop/overrun jump */
+static bool overrun;
+
+/*
+ * Set when the body called pg_prop_skip(): the property is abandoned and
+ * reported skipped.  Module-global (not per-case) because the whole property
+ * is skipped, not one case; run_property reads and clears it.
+ */
+static bool skip_requested;
+
+/*
+ * Vacuous-pass guard (see pg_prop_nonvacuous).  opted_in becomes true the
+ * first time a property calls pg_prop_nonvacuous in a run; nonvacuous_cases
+ * counts the cases that reached their invariant check.  A run that opted in
+ * but ran zero non-vacuous cases is reported as a failure, not a silent pass.
+ * Both are reset at the start of each property's run.
+ */
+static bool nonvacuous_opted_in;
+static uint64 nonvacuous_cases;
+
+/* "area/property" now running, for diagnostics (see pg_prop_sql.c). */
+const char *pg_prop_current_area = NULL;
+static char current_area_buf[256];
+static StringInfoData skip_reason;
+static bool skip_reason_inited;
+
+static void
+stop_test(pg_prop_tc *tc)
+{
+	overrun = true;
+	siglongjmp(tc->jmp, 1);
+}
+
+/* ---- generators ------------------------------------------------------ */
+
+int64
+pg_prop_draw_int(pg_prop_tc *tc, int64 min, int64 max)
+{
+	int64		out = 0;
+	hegel_result_t rc = hegel_generate_integer(tc->ctx, tc->tc, min, max, &out);
+
+	if (rc == HEGEL_E_STOP_TEST)
+		stop_test(tc);
+	check_rc(tc->ctx, rc, "generate_integer");
+	return out;
+}
+
+double
+pg_prop_draw_float(pg_prop_tc *tc, double min, double max)
+{
+	double		out = 0;
+	hegel_result_t rc;
+
+	/*
+	 * Full doubles including NaN and the infinities only when the range is
+	 * unbounded on the matching side, matching the engine's own defaults.
+	 */
+	rc = hegel_generate_float(tc->ctx, tc->tc, 64, min, max,
+							  /* allow_nan */ isinf(min) || isinf(max) ? false : false,
+							  /* allow_infinity */ false,
+							  false, false, 5e-324, &out);
+	if (rc == HEGEL_E_STOP_TEST)
+		stop_test(tc);
+	check_rc(tc->ctx, rc, "generate_float");
+	return out;
+}
+
+bool
+pg_prop_draw_bool(pg_prop_tc *tc)
+{
+	bool		out = false;
+	hegel_result_t rc = hegel_generate_boolean(tc->ctx, tc->tc, 0.5,
+											   false, false, &out);
+
+	if (rc == HEGEL_E_STOP_TEST)
+		stop_test(tc);
+	check_rc(tc->ctx, rc, "generate_boolean");
+	return out;
+}
+
+unsigned char *
+pg_prop_draw_bytes(pg_prop_tc *tc, size_t min_size, size_t max_size, size_t *len)
+{
+	hegel_generate_bytes_result_t res = {NULL, 0};
+	hegel_result_t rc = hegel_generate_bytes(tc->ctx, tc->tc, min_size,
+											 max_size, &res);
+	unsigned char *out;
+
+	if (rc == HEGEL_E_STOP_TEST)
+		stop_test(tc);
+	check_rc(tc->ctx, rc, "generate_bytes");
+
+	out = pg_malloc(res.len + 1);
+	if (res.len > 0)
+		memcpy(out, res.data, res.len);
+	out[res.len] = '\0';
+	*len = res.len;
+	hegel_generate_bytes_result_free(tc->ctx, &res);
+	return out;
+}
+
+/*
+ * Shared text-draw path: build a one-shot string generator, draw from it,
+ * copy the result out, free both.  Building a generator per draw is cheap
+ * relative to a round trip through the engine and keeps the API stateless.
+ */
+static char *
+draw_string(pg_prop_tc *tc, hegel_string_generator_t *gen, size_t *len)
+{
+	hegel_generate_string_result_t res = {NULL, 0};
+	hegel_result_t rc = hegel_generate_string(tc->ctx, tc->tc, gen, &res);
+	char	   *out;
+
+	if (rc == HEGEL_E_STOP_TEST)
+	{
+		hegel_string_generator_free(tc->ctx, gen);
+		stop_test(tc);
+	}
+	if (rc == HEGEL_E_ASSUME)
+	{
+		/* generator rejected its own draw (e.g. length cap); discard case */
+		hegel_string_generator_free(tc->ctx, gen);
+		siglongjmp(tc->jmp, 2); /* 2 == assume/invalid */
+	}
+	check_rc(tc->ctx, rc, "generate_string");
+
+	out = pg_malloc(res.len + 1);
+	if (res.len > 0)
+		memcpy(out, res.data, res.len);
+	out[res.len] = '\0';
+	*len = res.len;
+	hegel_generate_string_result_free(tc->ctx, &res);
+	hegel_string_generator_free(tc->ctx, gen);
+	return out;
+}
+
+char *
+pg_prop_draw_utf8(pg_prop_tc *tc, size_t min_size, size_t max_size, size_t *len)
+{
+	hegel_string_generator_t *gen = NULL;
+	hegel_result_t rc;
+
+	rc = hegel_string_generator_text(tc->ctx, min_size, max_size,
+									 "utf-8", 0, UINT32_MAX,
+									 NULL, 0, NULL, 0, NULL, 0, NULL, 0, &gen);
+	check_rc(tc->ctx, rc, "string_generator_text");
+	return draw_string(tc, gen, len);
+}
+
+char *
+pg_prop_draw_from(pg_prop_tc *tc, const char *alphabet,
+				  size_t min_size, size_t max_size, size_t *len)
+{
+	hegel_string_generator_t *gen = NULL;
+	hegel_result_t rc;
+
+	/*
+	 * There is no "alphabet" field in the C ABI's text generator; emulate it
+	 * with include_characters plus an empty category set, which restricts the
+	 * draw to exactly those characters.
+	 */
+	rc = hegel_string_generator_text(tc->ctx, min_size, max_size,
+									 "ascii", 0, UINT32_MAX,
+	/* categories: non-NULL empty list -> empty base alphabet */
+									 (const char *const *) &alphabet, 0,
+									 NULL, 0,
+									 (const unsigned char *) alphabet, strlen(alphabet),
+									 NULL, 0, &gen);
+	check_rc(tc->ctx, rc, "string_generator_text(alphabet)");
+	return draw_string(tc, gen, len);
+}
+
+/* ---- assume / note / fail -------------------------------------------- */
+
+void
+pg_prop_assume(pg_prop_tc *tc, bool condition)
+{
+	if (!condition)
+		siglongjmp(tc->jmp, 2); /* 2 == assume/invalid */
+}
+
+void
+pg_prop_note(pg_prop_tc *tc, const char *fmt,...)
+{
+	va_list		ap;
+	StringInfoData s;
+
+	initStringInfo(&s);
+	for (;;)
+	{
+		int			needed;
+
+		va_start(ap, fmt);
+		needed = appendStringInfoVA(&s, fmt, ap);
+		va_end(ap);
+		if (needed == 0)
+			break;
+		enlargeStringInfo(&s, needed);
+	}
+	hegel_note(tc->ctx, tc->tc, (const unsigned char *) s.data, s.len);
+	pfree(s.data);
+}
+
+void
+pg_prop_nonvacuous(pg_prop_tc *tc)
+{
+	(void) tc;
+	nonvacuous_opted_in = true;
+	nonvacuous_cases++;
+}
+
+void
+pg_prop_opt_into_vacuous_guard(pg_prop_tc *tc)
+{
+	(void) tc;
+	nonvacuous_opted_in = true;
+}
+
+void
+pg_prop_fail(pg_prop_tc *tc, const char *fmt,...)
+{
+	va_list		ap;
+
+	resetStringInfo(&tc->failmsg);
+	for (;;)
+	{
+		int			needed;
+
+		va_start(ap, fmt);
+		needed = appendStringInfoVA(&tc->failmsg, fmt, ap);
+		va_end(ap);
+		if (needed == 0)
+			break;
+		enlargeStringInfo(&tc->failmsg, needed);
+	}
+	tc->failed = true;
+	siglongjmp(tc->jmp, 1);		/* 1 == interesting/failure */
+}
+
+void
+pg_prop_skip(pg_prop_tc *tc, const char *fmt,...)
+{
+	va_list		ap;
+
+	if (!skip_reason_inited)
+	{
+		initStringInfo(&skip_reason);
+		skip_reason_inited = true;
+	}
+	resetStringInfo(&skip_reason);
+	for (;;)
+	{
+		int			needed;
+
+		va_start(ap, fmt);
+		needed = appendStringInfoVA(&skip_reason, fmt, ap);
+		va_end(ap);
+		if (needed == 0)
+			break;
+		enlargeStringInfo(&skip_reason, needed);
+	}
+	skip_requested = true;
+	siglongjmp(tc->jmp, 3);		/* 3 == skip the whole property */
+}
+
+/* ---- fork isolation -------------------------------------------------- */
+
+int
+pg_prop_run_isolated(pg_prop_isolated_fn body, void *arg, char **detail)
+{
+	pid_t		pid;
+	int			status;
+
+	if (detail)
+		*detail = NULL;
+
+	fflush(stdout);
+	fflush(stderr);
+	pid = fork();
+	if (pid < 0)
+	{
+		if (detail)
+			*detail = pg_strdup("fork failed");
+		return -1;
+	}
+	if (pid == 0)
+	{
+		/* child: run the code under test, then _exit so no atexit/flush runs */
+		body(arg);
+		fflush(stdout);
+		fflush(stderr);
+		_exit(0);
+	}
+
+	/* parent: wait and classify */
+	while (waitpid(pid, &status, 0) < 0)
+	{
+		if (errno != EINTR)
+		{
+			if (detail)
+				*detail = pg_strdup("waitpid failed");
+			return -1;
+		}
+	}
+
+	if (WIFEXITED(status))
+	{
+		int			code = WEXITSTATUS(status);
+
+		if (code == 0)
+			return 0;
+		if (detail)
+		{
+			char		buf[64];
+
+			snprintf(buf, sizeof(buf), "child exited with status %d", code);
+			*detail = pg_strdup(buf);
+		}
+		return code ? code : 1;
+	}
+	if (WIFSIGNALED(status))
+	{
+		int			sig = WTERMSIG(status);
+
+		if (detail)
+		{
+			char		buf[96];
+
+			snprintf(buf, sizeof(buf), "child killed by signal %d (%s)",
+					 sig, strsignal(sig));
+			*detail = pg_strdup(buf);
+		}
+		/* encode as a distinct non-zero code */
+		return 1000 + sig;
+	}
+	if (detail)
+		*detail = pg_strdup("child stopped or unknown status");
+	return -1;
+}
+
+/* ---- driver ---------------------------------------------------------- */
+
+/*
+ * Per-property run state passed from run_property() through the body-runner.
+ * libhegel's run loop hands us a test case; we build a pg_prop_tc, run the
+ * body under setjmp, and mark the case.
+ */
+typedef struct RunState
+{
+	PropEntry  *prop;
+	StringInfoData last_fail;	/* message from the last failing body */
+} RunState;
+
+static hegel_verbosity_t verbosity = HEGEL_VERBOSITY_NORMAL;
+
+/*
+ * Run the body for one test case handle; returns the hegel_status_t to pass
+ * to hegel_mark_complete.  Captures the failure message into rs->last_fail.
+ */
+static uint32_t
+run_one_case(RunState *rs, hegel_context_t *ctx, hegel_test_case_t *handle)
+{
+	pg_prop_tc	t;
+	int			jmpval;
+	uint32_t	status;
+
+	t.ctx = ctx;
+	t.tc = handle;
+	t.failed = false;
+	t.origin = rs->prop->name;
+	initStringInfo(&t.failmsg);
+	current_tc = &t;
+	overrun = false;
+
+	jmpval = sigsetjmp(t.jmp, 1);
+	if (jmpval == 0)
+	{
+		rs->prop->fn(&t, rs->prop->arg);
+		/* body returned normally: property held for this case */
+		status = HEGEL_STATUS_VALID;
+	}
+	else if (jmpval == 2)
+	{
+		/* assume/invalid */
+		status = HEGEL_STATUS_INVALID;
+	}
+	else if (overrun)
+	{
+		status = HEGEL_STATUS_OVERRUN;
+	}
+	else if (jmpval == 3)
+	{
+		/* skip: the whole property is abandoned; treat this case as valid so
+		 * the engine does not try to shrink it.  skip_requested is read by
+		 * run_property, which reports the property as skipped. */
+		status = HEGEL_STATUS_VALID;
+	}
+	else
+	{
+		/* failure: keep the message for the report */
+		resetStringInfo(&rs->last_fail);
+		appendBinaryStringInfo(&rs->last_fail, t.failmsg.data, t.failmsg.len);
+		status = HEGEL_STATUS_INTERESTING;
+	}
+
+	pfree(t.failmsg.data);
+
+	/*
+	 * Close any SQL connection the body opened and did not close -- a body
+	 * that failed or assumed away longjmp'd over its own pg_prop_disconnect().
+	 * This must run on every exit path, or a long run exhausts the server's
+	 * connection slots.  A no-op for in-process (Tier A) areas.
+	 */
+	pg_prop_sql_cleanup();
+
+	return status;
+}
+
+/*
+ * Replay a counterexample blob to re-derive the failing message and drawn
+ * values for the report.  Returns the (malloc'd) failure message.
+ */
+static char *
+replay_blob(RunState *rs, hegel_context_t *ctx, const hegel_settings_t *s,
+			const char *blob)
+{
+	hegel_test_case_t *handle = NULL;
+	hegel_result_t rc;
+	uint32_t	status;
+	char	   *msg;
+
+	rc = hegel_test_case_from_blob(ctx, s, blob, NULL, NULL, &handle);
+	if (rc != HEGEL_OK || handle == NULL)
+		return pg_strdup("(could not replay counterexample)");
+
+	status = run_one_case(rs, ctx, handle);
+	hegel_mark_complete(ctx, handle, status,
+						status == HEGEL_STATUS_INTERESTING ? rs->prop->name : NULL);
+	hegel_test_case_free(ctx, handle);
+
+	if (status == HEGEL_STATUS_INTERESTING && rs->last_fail.len > 0)
+		msg = pg_strdup(rs->last_fail.data);
+	else
+		msg = pg_strdup("(counterexample did not reproduce)");
+	return msg;
+}
+
+/*
+ * Run one property to completion.  Returns true if it held, false if a
+ * counterexample was found.  On failure, prints the message, the minimal
+ * counterexample's drawn values (via replay), and the replay blob as TAP
+ * diagnostics.  "testno" is the 1-based TAP point number.
+ */
+static bool
+run_property(PropEntry *prop, uint64 iterations, bool have_seed, uint64 seed,
+			 const char *dbpath, int testno, bool tap)
+{
+	hegel_context_t *ctx = hegel_context_new();
+	hegel_settings_t *s = NULL;
+	hegel_run_t *run = NULL;
+	hegel_run_result_t *result = NULL;
+	hegel_run_status_t rstatus;
+	RunState	rs;
+	bool		ok;
+	bool		vacuous_fail = false;
+
+	char		dbkey[256];
+
+	rs.prop = prop;
+	initStringInfo(&rs.last_fail);
+
+	check_rc(ctx, hegel_settings_new(ctx, &s), "settings_new");
+	check_rc(ctx, hegel_settings_set_test_cases(ctx, s, iterations), "set_test_cases");
+	check_rc(ctx, hegel_settings_set_verbosity(ctx, s, verbosity), "set_verbosity");
+	check_rc(ctx, hegel_settings_set_seed(ctx, s, seed, have_seed), "set_seed");
+
+	/*
+	 * Under a deep hunt (PG_PROP_DEEP set), a test case's "generation" is
+	 * dominated by server-side DDL/DML building the database, which on a
+	 * loaded machine routinely exceeds the engine's 30-second TooSlow
+	 * threshold -- the check then measures server load, not the engine, and
+	 * aborts the run.  Suppress just that check in hunt mode; the committed
+	 * TAP run (PG_PROP_DEEP unset) keeps every health check.
+	 */
+	if (getenv("PG_PROP_DEEP") != NULL && getenv("PG_PROP_DEEP")[0] != '\0' &&
+		strcmp(getenv("PG_PROP_DEEP"), "0") != 0)
+		check_rc(ctx, hegel_settings_set_suppress_health_check(ctx, s, HEGEL_HC_TOO_SLOW),
+				 "suppress_health_check");
+	snprintf(dbkey, sizeof(dbkey), "%s/%s", prop->area, prop->name);
+	snprintf(current_area_buf, sizeof(current_area_buf), "%s", dbkey);
+	pg_prop_current_area = current_area_buf;
+	check_rc(ctx, hegel_settings_set_database_key(ctx, s, dbkey), "set_db_key");
+	check_rc(ctx, hegel_settings_set_database(ctx, s, dbpath ? dbpath : ""),
+			 "set_database");
+
+	check_rc(ctx, hegel_run_start(ctx, s, NULL, NULL, &run), "run_start");
+
+	skip_requested = false;
+	nonvacuous_opted_in = false;
+	nonvacuous_cases = 0;
+
+	for (;;)
+	{
+		hegel_test_case_t *handle = NULL;
+		uint32_t	status;
+
+		check_rc(ctx, hegel_next_test_case(ctx, run, &handle), "next_test_case");
+		if (handle == NULL)
+			break;
+		status = run_one_case(&rs, ctx, handle);
+		hegel_mark_complete(ctx, handle, status,
+							status == HEGEL_STATUS_INTERESTING ? prop->name : NULL);
+		hegel_test_case_free(ctx, handle);
+	}
+
+	check_rc(ctx, hegel_run_result(ctx, run, &result), "run_result");
+	check_rc(ctx, hegel_run_result_status(ctx, result, &rstatus), "run_result_status");
+
+	ok = (rstatus == HEGEL_RUN_STATUS_PASSED);
+
+	/*
+	 * The body called pg_prop_skip(): report the property as skipped (TAP
+	 * SKIP directive) and count it as passed, regardless of the engine's
+	 * status for the one case that ran before the skip.
+	 */
+	if (skip_requested)
+	{
+		const char *reason = skip_reason_inited ? skip_reason.data : "";
+
+		if (tap)
+			printf("ok %d - %s/%s # SKIP %s\n", testno,
+				   prop->area, prop->name, reason);
+		else
+			printf("%s/%s: SKIPPED (%s)\n", prop->area, prop->name, reason);
+		hegel_run_result_free(ctx, result);
+		hegel_run_free(ctx, run);
+		hegel_settings_free(ctx, s);
+		hegel_context_free(ctx);
+		pfree(rs.last_fail.data);
+		return true;
+	}
+
+	/*
+	 * Vacuous-pass guard: a property that opted in (called pg_prop_nonvacuous
+	 * in its live path) but reached that point in zero cases tested nothing,
+	 * even though the engine reported PASSED.  Fail it loudly rather than
+	 * report a false green.  A genuine failure (ok already false) is left
+	 * alone.
+	 */
+	if (ok && nonvacuous_opted_in && nonvacuous_cases == 0)
+	{
+		ok = false;
+		vacuous_fail = true;
+	}
+
+	if (tap)
+		printf("%s %d - %s/%s\n", ok ? "ok" : "not ok", testno,
+			   prop->area, prop->name);
+	else
+		printf("%s/%s: %s\n", prop->area, prop->name,
+			   ok ? "PASSED" : "FAILED");
+
+	if (!ok)
+	{
+		const char *pfx = tap ? "# " : "    ";
+
+		if (vacuous_fail)
+		{
+			printf("%svacuous: property ran 0 non-vacuous cases -- every case "
+				   "returned early before its invariant check (database too "
+				   "small, generated SQL invalid, or the server unreachable). "
+				   "The run tested nothing; not reporting it as passed.\n", pfx);
+		}
+		else if (rstatus == HEGEL_RUN_STATUS_ERROR)
+		{
+			const char *err = NULL;
+
+			hegel_run_result_error(ctx, result, &err);
+			printf("%srun error: %s\n", pfx, err ? err : "(unknown)");
+		}
+		else
+		{
+			size_t		count = 0;
+
+			hegel_run_result_failure_count(ctx, result, &count);
+			for (size_t i = 0; i < count; i++)
+			{
+				hegel_failure_t *f = NULL;
+				const char *blob = NULL;
+				char	   *msg;
+
+				hegel_run_result_failure(ctx, result, i, &f);
+				hegel_failure_reproduction_blob(ctx, f, &blob);
+				msg = replay_blob(&rs, ctx, s, blob);
+
+				printf("%sfalsified: %s\n", pfx, msg);
+				if (blob)
+					printf("%sreplay: PG_PROP_SEED unavailable; rerun with "
+						   "--blob to reproduce: %s\n", pfx, blob);
+				pg_free(msg);
+				hegel_failure_free(ctx, f);
+			}
+		}
+	}
+	fflush(stdout);
+
+	pfree(rs.last_fail.data);
+	hegel_run_result_free(ctx, result);
+	hegel_run_free(ctx, run);
+	hegel_settings_free(ctx, s);
+	hegel_context_free(ctx);
+	return ok;
+}
+
+/* ---- CLI ------------------------------------------------------------- */
+
+static void
+usage(const char *argv0)
+{
+	printf("Usage: %s AREA [options] [PROPERTY ...]\n\n", argv0);
+	printf("  AREA                  which group of properties to run\n");
+	printf("                        (run with --list to see the areas)\n");
+	printf("  --list                list AREA/PROPERTY names and exit\n");
+	printf("  -n, --iterations N    test cases per property (default 100)\n");
+	printf("  -s, --seed N          fixed RNG seed (default: random)\n");
+	printf("  -v, --verbose         raise engine verbosity\n");
+	printf("  -q, --quiet           lower engine verbosity\n");
+	printf("      --no-tap          plain output instead of TAP\n");
+	printf("  -h, --help            this help\n\n");
+	printf("With no PROPERTY, every property in AREA runs.  Examples:\n");
+	printf("  %s jsonapi --list\n", argv0);
+	printf("  %s path canonicalize_idempotent -n 100000 -s 42\n\n", argv0);
+	printf("Environment overrides: PG_PROP_ITERATIONS, PG_PROP_SEED, PG_PROP_TESTS.\n");
+}
+
+/* Does name match any of the selectors? */
+static bool
+selected(const char *name, char **sel, int nsel)
+{
+	if (nsel == 0)
+		return true;
+	for (int i = 0; i < nsel; i++)
+		if (strcmp(name, sel[i]) == 0)
+			return true;
+	return false;
+}
+
+int
+main(int argc, char **argv)
+{
+	static struct option long_options[] = {
+		{"list", no_argument, NULL, 'L'},
+		{"iterations", required_argument, NULL, 'n'},
+		{"seed", required_argument, NULL, 's'},
+		{"verbose", no_argument, NULL, 'v'},
+		{"quiet", no_argument, NULL, 'q'},
+		{"no-tap", no_argument, NULL, 'T'},
+		{"help", no_argument, NULL, 'h'},
+		{NULL, 0, NULL, 0}
+	};
+	uint64		iterations = 100;
+	bool		have_seed = false;
+	uint64		seed = 0;
+	bool		do_list = false;
+	bool		tap = true;
+	const char *env;
+	const char *dbpath;
+	const char *area = NULL;
+	int			c;
+	int			npass = 0;
+	int			nrun = 0;
+	int			nsel;
+	char	  **sel;
+
+	pg_logging_init(argv[0]);
+
+	/* environment defaults (command line wins) */
+	if ((env = getenv("PG_PROP_ITERATIONS")) != NULL && env[0])
+		iterations = strtou64(env, NULL, 10);
+	if ((env = getenv("PG_PROP_SEED")) != NULL && env[0])
+	{
+		seed = strtou64(env, NULL, 10);
+		have_seed = true;
+	}
+
+	while ((c = getopt_long(argc, argv, "n:s:vqhL", long_options, NULL)) != -1)
+	{
+		switch (c)
+		{
+			case 'L':
+				do_list = true;
+				break;
+			case 'n':
+				iterations = strtou64(optarg, NULL, 10);
+				break;
+			case 's':
+				seed = strtou64(optarg, NULL, 10);
+				have_seed = true;
+				break;
+			case 'v':
+				verbosity = HEGEL_VERBOSITY_VERBOSE;
+				break;
+			case 'q':
+				verbosity = HEGEL_VERBOSITY_QUIET;
+				break;
+			case 'T':
+				tap = false;
+				break;
+			case 'h':
+				usage(argv[0]);
+				return 0;
+			default:
+				usage(argv[0]);
+				return 2;
+		}
+	}
+
+	if (do_list)
+	{
+		for (int i = 0; i < nprops; i++)
+			printf("%s/%s\n", props[i].area, props[i].name);
+		return 0;
+	}
+
+	/*
+	 * First positional argument selects the area (the target group).  Running
+	 * every area at once is intentionally not supported from one invocation:
+	 * the TAP driver runs one area per binary subcommand, and a developer
+	 * debugging a failure wants one area in front of them.
+	 */
+	if (optind >= argc)
+	{
+		fprintf(stderr, "pg_prop: missing AREA argument "
+				"(try --list, or -h for help)\n");
+		return 2;
+	}
+	area = argv[optind++];
+
+	/* remaining positionals, else PG_PROP_TESTS, select properties by name */
+	sel = &argv[optind];
+	nsel = argc - optind;
+	if (nsel == 0 && (env = getenv("PG_PROP_TESTS")) != NULL && env[0])
+	{
+		/* split PG_PROP_TESTS on whitespace into a fresh argv-like array */
+		static char *envsel[MAX_PROPS];
+		char	   *dup = pg_strdup(env);
+		char	   *tok = strtok(dup, " \t\n");
+
+		nsel = 0;
+		while (tok && nsel < MAX_PROPS)
+		{
+			envsel[nsel++] = tok;
+			tok = strtok(NULL, " \t\n");
+		}
+		sel = envsel;
+	}
+
+	/*
+	 * Pick and announce a seed when none was given, so a failure seen on a
+	 * buildfarm animal can be reproduced exactly.  derandomize is left off:
+	 * we want fresh randomness unless the caller pins a seed.
+	 */
+	if (!have_seed)
+	{
+		srandom((unsigned int) (getpid() ^ (unsigned int) time(NULL)));
+		seed = ((uint64) random() << 32) ^ (uint64) random();
+		have_seed = true;
+		if (tap)
+			printf("# seed: %llu (rerun with -s %llu)\n",
+				   (unsigned long long) seed, (unsigned long long) seed);
+		else
+			printf("seed: %llu\n", (unsigned long long) seed);
+	}
+
+	/*
+	 * The example database persists counterexamples between runs so a known
+	 * failure replays first.  Keep it out of the source tree: honour
+	 * TESTDATADIR (set by the TAP wrapper) when present, else disable it.
+	 */
+	dbpath = getenv("PG_PROP_DB");
+	if (dbpath == NULL)
+	{
+		const char *td = getenv("TESTDATADIR");
+
+		dbpath = td ? NULL : "";	/* NULL under TESTDATADIR -> default .hegel
+									 * there; handled below */
+		if (td)
+		{
+			static char dbbuf[MAXPGPATH];
+
+			snprintf(dbbuf, sizeof(dbbuf), "%s/hegel-db", td);
+			dbpath = dbbuf;
+		}
+	}
+
+	/* count the selected properties for the TAP plan */
+	if (tap)
+	{
+		int			plan = 0;
+
+		for (int i = 0; i < nprops; i++)
+			if (strcmp(props[i].area, area) == 0 &&
+				selected(props[i].name, sel, nsel))
+				plan++;
+		printf("1..%d\n", plan);
+	}
+
+	for (int i = 0; i < nprops; i++)
+	{
+		if (strcmp(props[i].area, area) != 0)
+			continue;
+		if (!selected(props[i].name, sel, nsel))
+			continue;
+		nrun++;
+		if (run_property(&props[i], iterations, have_seed, seed, dbpath,
+						 nrun, tap))
+			npass++;
+	}
+
+	if (nrun == 0)
+	{
+		fprintf(stderr, "pg_prop: no matching properties in area \"%s\"\n", area);
+		return 2;
+	}
+	if (!tap)
+		printf("%d/%d properties passed\n", npass, nrun);
+
+	return (npass == nrun) ? 0 : 1;
+}
diff --git a/src/test/prop/pg_prop.h b/src/test/prop/pg_prop.h
new file mode 100644
index 00000000000..c3dca7da502
--- /dev/null
+++ b/src/test/prop/pg_prop.h
@@ -0,0 +1,381 @@
+/*-------------------------------------------------------------------------
+ *
+ * pg_prop.h
+ *	  Property-based testing harness for PostgreSQL, built on libhegel.
+ *
+ * This is the single entry point for every property-based test binary under
+ * src/test/prop.  It wraps the libhegel C ABI (hegel.h) in a small, plain-C
+ * API so individual test files never touch the engine handles directly, and
+ * so a test file reads like an ordinary C unit test.
+ *
+ * A property is a function:
+ *
+ *		static void prop_my_thing(pg_prop_tc *tc, void *arg)
+ *		{
+ *			int64 n = pg_prop_draw_int(tc, INT64_MIN, INT64_MAX);
+ *			if (!my_invariant(n))
+ *				pg_prop_fail(tc, "invariant violated for %lld",
+ *							 (long long) n);
+ *		}
+ *
+ * registered with PG_PROP() and run from the generated main().  The engine
+ * generates many inputs, and on failure shrinks to a minimal counterexample
+ * and prints a base64 blob that replays it exactly.
+ *
+ * Why a C API and not hegel-cpp: PostgreSQL is a C project maintained by C
+ * programmers.  libhegel ships a stable C ABI; we bind to it directly.  The
+ * only C++-ism anywhere in this tree is that the engine's own shared library
+ * is built in Rust; nothing we write is C++.
+ *
+ * The isolate() facility (pg_prop_run_isolated) forks a child to run code
+ * that may crash, abort() on a failed Assert, or exit() on a fatal error --
+ * all common in frontend PostgreSQL code -- and turns any of those into an
+ * ordinary property failure the engine can shrink.  Without it, a crash in
+ * the code under test would take the whole test process down and lose the
+ * counterexample.
+ *
+ * Copyright (c) 2026, PostgreSQL Global Development Group
+ *
+ * IDENTIFICATION
+ *	  src/test/prop/pg_prop.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PG_PROP_H
+#define PG_PROP_H
+
+#include "postgres_fe.h"
+
+/*
+ * Opaque per-test-case handle.  One is handed to each property invocation;
+ * it must not be stored past the end of that invocation.
+ */
+typedef struct pg_prop_tc pg_prop_tc;
+
+/* A property function: draws inputs from tc, checks, calls pg_prop_fail. */
+typedef void (*pg_prop_fn) (pg_prop_tc *tc, void *arg);
+
+/*
+ * Register a property.  Use the PG_PROP() macro rather than calling this
+ * directly.  "area" groups properties that share a target (and a binary
+ * subcommand); "name" selects one on the command line and names it in the
+ * example database as "area/name"; "fn" is the property body.
+ */
+extern void pg_prop_register(const char *area, const char *name,
+							 pg_prop_fn fn, void *arg);
+
+/*
+ * Each prop_<area>.c defines PG_PROP_AREA to its area name before including
+ * this header:
+ *
+ *		#define PG_PROP_AREA "path"
+ *		#include "pg_prop.h"
+ *
+ * PG_PROP(ident) then defines and registers a property body under that area.
+ * Run it as:  pg_prop <area> [options] [name ...]
+ *
+ * The harness translation unit (pg_prop.c) includes this header without
+ * defining PG_PROP_AREA, which is fine -- it never uses PG_PROP().  Only a
+ * use of PG_PROP() without an area is an error, enforced below.
+ */
+#ifndef PG_PROP_AREA
+#define PG_PROP_AREA pg_prop_area_not_defined
+#endif
+
+#define PG_PROP(ident) \
+	static void ident(pg_prop_tc *tc, void *arg); \
+	static void pg_prop_reg_##ident(void) pg_attribute_constructor; \
+	static void pg_prop_reg_##ident(void) \
+	{ \
+		pg_prop_register(PG_PROP_AREA, CppAsString(ident), ident, NULL); \
+	} \
+	static void ident(pg_prop_tc *tc, void *arg)
+
+/*
+ * Extension points (hooks).
+ *
+ * Two ways an extension can participate in this suite:
+ *
+ *	 (a) Add a whole new area.  Compile a prop_<ext>.c into the pg_prop binary
+ *		 (see src/test/prop/README, "Extensions") that defines PG_PROP_AREA to
+ *		 the extension's name and uses PG_PROP() exactly as a core area does.
+ *		 Nothing else is needed: the constructor registers it and it appears as
+ *		 its own subcommand.  CREATE EXTENSION in the area's connect helper.
+ *
+ *	 (b) Augment an existing area.  A core area may publish a named extension
+ *		 point and call pg_prop_run_hooks() at it; an extension registers a
+ *		 contributor with PG_PROP_HOOK(point).  The contributor receives the
+ *		 point name and a void *ctx whose concrete type is documented by the
+ *		 point.  For example, the SQL query generator (sql/query_gen.h)
+ *		 publishes "query_gen.column" and "query_gen.predicate" so an
+ *		 extension type can add columns and predicate leaves to the generated
+ *		 tables and queries, after which every query-level oracle (TLP, NoREC,
+ *		 cardinality, PQS, plan-advice) covers that type with no further work.
+ *
+ * A hook contributor:
+ *
+ *		static void my_ext_cols(pg_prop_tc *tc, const char *point, void *ctx)
+ *		{
+ *			qg_schema *s = ctx;		// concrete type for this point
+ *			qg_add_column(s, "ck", "citext");
+ *		}
+ *		PG_PROP_HOOK("query_gen.column", my_ext_cols);
+ */
+typedef void (*pg_prop_hook_fn) (pg_prop_tc *tc, const char *point, void *ctx);
+
+extern void pg_prop_register_hook(const char *point, pg_prop_hook_fn fn);
+extern int	pg_prop_hooks_count(const char *point);
+extern void pg_prop_run_hooks(pg_prop_tc *tc, const char *point, void *ctx);
+extern void pg_prop_run_hook_n(pg_prop_tc *tc, const char *point, int n,
+							   void *ctx);
+
+/*
+ * Register a hook contributor before main().  "point" is the extension-point
+ * name; "fn" runs when the publishing area reaches that point.  Place these at
+ * file scope in an extension's prop_<ext>.c.
+ */
+#define PG_PROP_HOOK(point, fn) \
+	static void pg_prop_hookreg_##fn(void) pg_attribute_constructor; \
+	static void pg_prop_hookreg_##fn(void) \
+	{ \
+		pg_prop_register_hook((point), (fn)); \
+	} \
+	struct pg_prop_hook_force_semicolon_##fn
+#ifndef pg_attribute_constructor
+#define pg_attribute_constructor __attribute__((constructor))
+#endif
+
+/*
+ * Draw functions.  Each pulls one value from the engine's choice stream for
+ * this test case.  Bounds are inclusive.  The engine deliberately favours
+ * boundary values (min, max, 0, -1, 1), so pass the widest bounds the code
+ * under test is supposed to accept -- narrowing them hides exactly the bugs
+ * this tool exists to find.
+ */
+extern int64 pg_prop_draw_int(pg_prop_tc *tc, int64 min, int64 max);
+extern double pg_prop_draw_float(pg_prop_tc *tc, double min, double max);
+extern bool pg_prop_draw_bool(pg_prop_tc *tc);
+
+/*
+ * Draw a byte buffer of length in [min_size, max_size].  The result is
+ * malloc'd and owned by the caller; free it.  *len receives the length.  The
+ * buffer is NUL-terminated for convenience (one extra byte past *len) but may
+ * contain interior NULs, so always use *len for the real content.
+ */
+extern unsigned char *pg_prop_draw_bytes(pg_prop_tc *tc, size_t min_size,
+										  size_t max_size, size_t *len);
+
+/*
+ * Draw a UTF-8 string of length in [min_size, max_size] code points, drawn
+ * from the full assigned Unicode range (not ASCII-only).  Caller frees.  *len
+ * receives the byte length.  NUL-terminated; may contain interior NULs.
+ */
+extern char *pg_prop_draw_utf8(pg_prop_tc *tc, size_t min_size,
+							   size_t max_size, size_t *len);
+
+/*
+ * Draw a string restricted to the given alphabet (a NUL-terminated C string
+ * of allowed ASCII characters).  Useful when the code under test is only
+ * meaningful on a restricted charset (identifiers, numeric text, ...).
+ * Caller frees.
+ */
+extern char *pg_prop_draw_from(pg_prop_tc *tc, const char *alphabet,
+							   size_t min_size, size_t max_size, size_t *len);
+
+/*
+ * Reject the current test case: its generated inputs do not satisfy a
+ * precondition, so it should be discarded rather than counted.  Use sparingly
+ * -- a high rejection rate trips a health check.  Prefer constructing valid
+ * inputs directly over drawing-then-rejecting.
+ */
+extern void pg_prop_assume(pg_prop_tc *tc, bool condition);
+
+/*
+ * Skip the whole property: its precondition cannot be met in this environment
+ * (a required extension or procedural language is not installed, a platform
+ * feature is absent).  Unlike pg_prop_assume -- which discards one generated
+ * case -- this abandons the entire property and reports it as skipped, with
+ * "reason" shown as the TAP SKIP directive.  Call it at the top of the body,
+ * before drawing inputs.  Does not return.
+ */
+extern pg_noreturn void pg_prop_skip(pg_prop_tc *tc, const char *fmt,...) pg_attribute_printf(2, 3);
+
+/*
+ * Attach a diagnostic note to the current case.  It is printed only when this
+ * case is the final shrunk counterexample, so use it to record context the
+ * drawn values alone don't show (e.g. an intermediate the property computed).
+ */
+extern void pg_prop_note(pg_prop_tc *tc, const char *fmt,...) pg_attribute_printf(2, 3);
+
+/*
+ * Mark that this case did real work: it reached its invariant check (ran the
+ * query, compared the oracle) rather than returning early as vacuous (the
+ * generated database was too small to join, the drawn SQL did not parse, a
+ * guard bailed out).  Call it at the point the case is about to assert its
+ * property, after any early-return guards.  Pair it with
+ * pg_prop_opt_into_vacuous_guard() at the top of the body.
+ *
+ * Why this exists: a property that returns early on every case is counted by
+ * the engine as PASSED, because an early return is indistinguishable from "the
+ * invariant held".  A validation run that tested nothing then reports success
+ * -- the worst failure mode a checker can have (it was a live bug: a startup
+ * slip left the server down, every deep case returned vacuous, and the suite
+ * reported all green while testing nothing).
+ */
+extern void pg_prop_nonvacuous(pg_prop_tc *tc);
+
+/*
+ * Declare that this property is subject to the vacuous-pass guard: call it
+ * once, unconditionally, at the top of the body (before any early-return
+ * guard).  It arms the guard for the whole run; pg_prop_nonvacuous then marks
+ * each case that reached its invariant check.  A run that armed the guard but
+ * marked zero non-vacuous cases is failed.
+ *
+ * Two calls rather than one because "this property wants the guard" and "this
+ * case did real work" are distinct: if a property only called nonvacuous and
+ * no case ever reached it, the harness could not tell an all-vacuous run
+ * (should fail) from a legacy property that never opted in (should pass).
+ * Arming it up front removes that ambiguity.
+ */
+extern void pg_prop_opt_into_vacuous_guard(pg_prop_tc *tc);
+
+/*
+ * Fail the current test case.  This is how a property reports that its
+ * invariant did not hold.  The message is the human-readable description of
+ * the failure; it is printed with the counterexample.  Does not return.
+ */
+extern pg_noreturn void pg_prop_fail(pg_prop_tc *tc, const char *fmt,...) pg_attribute_printf(2, 3);
+
+/*
+ * Run "body" in a forked child.  Returns 0 if the child exited 0, otherwise a
+ * non-zero code describing how it died.  *detail (if non-NULL) receives a
+ * malloc'd human-readable description ("exited N", "signal N (SIGSEGV)", ...).
+ *
+ * Use this to wrap any call into code that may crash, abort() on a failed
+ * assertion, or exit() -- so the failure becomes a value you can test and
+ * pg_prop_fail on, with the drawn inputs still in hand to shrink.  Draw your
+ * inputs in the parent (before calling this) so the engine sees them; the
+ * child only runs the code under test.
+ */
+typedef void (*pg_prop_isolated_fn) (void *arg);
+extern int	pg_prop_run_isolated(pg_prop_isolated_fn body, void *arg,
+								 char **detail);
+
+/*
+ * SQL oracle support (Tier B).
+ *
+ * Backend code -- the planner, executor, type I/O -- cannot be linked into a
+ * frontend binary, so properties that exercise it instead drive SQL against a
+ * live cluster and compare the answer to an oracle (a reference query, or the
+ * same query with an optimisation disabled).  The TAP driver t/002_sql_props.pl
+ * starts one node and exports PGHOST/PGPORT; these helpers connect to it.
+ *
+ * A SQL area opens one connection in its property body (connections are cheap
+ * against a local socket and keeping them per-case keeps state from leaking
+ * between cases).  The handle is an opaque PGconn-wrapper so pg_prop.h need
+ * not include libpq-fe.h.
+ */
+typedef struct pg_prop_conn pg_prop_conn;
+
+/*
+ * Ensure an extension (contrib module or procedural language) is available for
+ * the session, loading it with CREATE EXTENSION IF NOT EXISTS.  Returns true
+ * if it is now present.  If the extension is not installed on this server,
+ * returns false; the caller then typically pg_prop_skip()s.  "conn" is an open
+ * connection.  Example:
+ *
+ *		if (!pg_prop_require_extension(conn, "pageinspect"))
+ *			pg_prop_skip(tc, "pageinspect not installed");
+ */
+extern bool pg_prop_require_extension(pg_prop_conn *conn, const char *extname);
+
+/*
+ * Connect to the test cluster named by PGHOST/PGPORT/PGDATABASE in the
+ * environment.  Returns NULL and sets *err (malloc'd) on failure; the caller
+ * typically pg_prop_fail()s on NULL.  Close with pg_prop_disconnect.
+ */
+extern pg_prop_conn *pg_prop_connect(char **err);
+extern void pg_prop_disconnect(pg_prop_conn *conn);
+
+/*
+ * Run "sql" and return its single-row, single-column result as a malloc'd
+ * string (caller frees), or NULL for SQL NULL.  On a SQL error, *errmsg (if
+ * non-NULL) receives a malloc'd message and the function returns NULL with
+ * *failed set true; callers distinguish a genuine NULL result from an error
+ * via *failed.  Use pg_prop_sql_scalar for the common one-value oracle.
+ */
+extern char *pg_prop_sql_scalar(pg_prop_conn *conn, const char *sql,
+								bool *failed, char **errmsg);
+
+/*
+ * Run "sql" expecting a result set, and return the whole thing as one newline-
+ * joined, row-major string (every value rendered, NULLs as "\\N"), suitable for
+ * comparing two queries that should return identical rows in identical order.
+ * Caller frees.  *failed / *errmsg as above.  The caller is responsible for
+ * ordering (add ORDER BY) when it wants order-insensitive comparison use a
+ * sorted projection.
+ */
+extern char *pg_prop_sql_rows(pg_prop_conn *conn, const char *sql,
+							  bool *failed, char **errmsg);
+
+/*
+ * Like pg_prop_sql_rows, but return an md5 digest of the whole result
+ * (computed server-side) instead of the full rendered text.  Use this for
+ * oracles over large/deep results: the client holds only a 32-char hash, so a
+ * result that would overflow the 1 GB StringInfo cap when rendered
+ * client-side (the deep generators can produce such results) still compares in
+ * O(1) client memory.  Two queries that return the same rows in the same order
+ * return the same digest; the caller supplies the ORDER BY that fixes the
+ * order.  Caller frees; *failed / *errmsg as above.
+ */
+extern char *pg_prop_sql_rowhash(pg_prop_conn *conn, const char *sql,
+								 bool *failed, char **errmsg);
+
+/*
+ * Like pg_prop_sql_rowhash but order-insensitive: the per-row text is sorted
+ * server-side before hashing, so two queries that return the same *multiset*
+ * of rows compare equal regardless of row order.  Use this for any oracle
+ * that compares results across plans or query rewrites: SQL never guarantees
+ * row order without ORDER BY, and even with ORDER BY a tie among equal sort
+ * keys (equal numeric scales, a nondeterministic collation, a FULL/RIGHT join
+ * that null-extends every key) leaves the order unspecified.  Comparing
+ * multisets removes that whole class of false positive (false_positives.md
+ * 2.1-2.2) and lets the generator emit shapes whose output has no total order.
+ * Counts and duplicates are preserved (it is a multiset, not a set).
+ */
+extern char *pg_prop_sql_rowhash_unordered(pg_prop_conn *conn, const char *sql,
+										   bool *failed, char **errmsg);
+
+/*
+ * Run "sql" for side effects (DDL/DML), returning true on success.  On error
+ * returns false and sets *errmsg (malloc'd).
+ */
+extern bool pg_prop_sql_exec(pg_prop_conn *conn, const char *sql, char **errmsg);
+
+/* "area/property" currently running (NULL outside a run); for diagnostics. */
+extern const char *pg_prop_current_area;
+
+/*
+ * Asynchronous statement support, for properties that need two sessions in
+ * flight at once (e.g. provoking a deadlock, where both sessions must be
+ * blocked before either can resolve).  pg_prop_sql_send dispatches "sql"
+ * without waiting (returns false if it could not be sent); pg_prop_sql_await
+ * then blocks for the result, returning true on success and, on error, the
+ * SQLSTATE via *sqlstate (malloc'd 5-char code, or NULL) so the caller can
+ * tell a deadlock (40P01) from a serialization failure (40001) or a lock
+ * timeout (55P03).  Use pg_prop_sql_send on both connections, then await each.
+ */
+extern bool pg_prop_sql_send(pg_prop_conn *conn, const char *sql);
+extern bool pg_prop_sql_await(pg_prop_conn *conn, char **sqlstate, char **errmsg);
+
+/*
+ * Close any connections still open at the end of a test case.  A property that
+ * fails or is assumed away unwinds by longjmp, jumping over its own
+ * pg_prop_disconnect(); the driver calls this after every case so a leaked
+ * connection cannot accumulate across the thousands of cases in a run (which
+ * would otherwise exhaust the server's connection slots).  Defined in
+ * pg_prop_sql.c; a no-op if no SQL area ever connected.
+ */
+extern void pg_prop_sql_cleanup(void);
+
+#endif							/* PG_PROP_H */
diff --git a/src/test/prop/pg_prop_sql.c b/src/test/prop/pg_prop_sql.c
new file mode 100644
index 00000000000..24f8841a51d
--- /dev/null
+++ b/src/test/prop/pg_prop_sql.c
@@ -0,0 +1,409 @@
+/*-------------------------------------------------------------------------
+ *
+ * pg_prop_sql.c
+ *	  libpq oracle helpers for Tier-B property tests.
+ *
+ * Backend code (planner, executor, type I/O) cannot be linked into a frontend
+ * binary, so properties that exercise it drive SQL against a live cluster and
+ * compare the result to an oracle.  These helpers wrap just enough of libpq
+ * for that: connect, run a scalar/rowset/DDL query, render the result as a
+ * string the property can compare.  See pg_prop.h for the contract.
+ *
+ * Copyright (c) 2026, PostgreSQL Global Development Group
+ *
+ * IDENTIFICATION
+ *	  src/test/prop/pg_prop_sql.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres_fe.h"
+
+#include <time.h>
+#include <unistd.h>
+
+#include "lib/stringinfo.h"
+#include "libpq-fe.h"
+#include "pg_prop.h"
+
+struct pg_prop_conn
+{
+	PGconn	   *conn;
+	struct pg_prop_conn *next;	/* link in the live-connection registry */
+};
+
+/*
+ * Live connections, so the driver can close any a failing/assumed-away case
+ * leaked by longjmp'ing over its pg_prop_disconnect().  One run is single-
+ * threaded, so a plain list needs no locking.
+ */
+static pg_prop_conn *live_conns = NULL;
+
+pg_prop_conn *
+pg_prop_connect(char **err)
+{
+	pg_prop_conn *c;
+	PGconn	   *conn;
+
+	/*
+	 * Empty connection string: everything comes from the environment
+	 * (PGHOST, PGPORT, PGDATABASE), which the TAP driver points at the test
+	 * node.  dbname defaults to "postgres" if PGDATABASE is unset.
+	 */
+	const char *keywords[] = {"dbname", NULL};
+	const char *values[] = {getenv("PGDATABASE") ? getenv("PGDATABASE") : "postgres", NULL};
+
+	conn = PQconnectdbParams(keywords, values, true);
+	if (PQstatus(conn) != CONNECTION_OK)
+	{
+		if (err)
+			*err = pg_strdup(PQerrorMessage(conn));
+		PQfinish(conn);
+		return NULL;
+	}
+	c = pg_malloc(sizeof(pg_prop_conn));
+	c->conn = conn;
+	c->next = live_conns;
+	live_conns = c;
+
+	/* Quieten DROP ... IF EXISTS notices and similar chatter. */
+	PQclear(PQexec(conn, "SET client_min_messages = warning"));
+
+	return c;
+}
+
+/* Unlink c from the live list, if present. */
+static void
+unlink_conn(pg_prop_conn *c);
+
+/*
+ * Crash tripwire.  A backend that dies (assertion failure, segfault) takes
+ * its connection with it, and the property would otherwise see only a
+ * confusing SQL failure, which the engine then "shrinks" by trying other
+ * inputs until the case stops reproducing -- by which time the postmaster is
+ * in crash recovery and every following area fails to connect.  So every
+ * statement helper calls this after PQexec: if the connection is now bad,
+ * the statement that killed it is appended to $PG_PROP_CRASH_LOG (default
+ * pg_prop_crashes.sql in the working directory), stamped with the area
+ * and time, and the run aborts at once with a distinct exit code.  The
+ * crashing statement is the reproducer; it is captured before shrinking can
+ * lose it, and nothing downstream runs against a recovering server.
+ */
+static void
+check_backend_alive(pg_prop_conn *conn, const char *sql)
+{
+	FILE	   *f;
+	const char *path;
+	time_t		now;
+
+	if (PQstatus(conn->conn) == CONNECTION_OK)
+		return;
+
+	path = getenv("PG_PROP_CRASH_LOG");
+	if (path == NULL || path[0] == '\0')
+		path = "pg_prop_crashes.sql";
+	now = time(NULL);
+	f = fopen(path, "a");
+	if (f != NULL)
+	{
+		fprintf(f, "-- backend died: area=%s pid=%d at %s"
+				"-- server said: %s\n%s;\n\n",
+				pg_prop_current_area ? pg_prop_current_area : "?",
+				(int) getpid(), ctime(&now),
+				PQerrorMessage(conn->conn), sql);
+		fclose(f);
+	}
+	fprintf(stderr,
+			"pg_prop: BACKEND CRASH -- the server connection died while running:\n"
+			"%s\n(statement saved to %s; aborting so no further area runs "
+			"against a recovering server)\n", sql, path);
+	fflush(stdout);
+	exit(3);
+}
+
+/* Unlink c from the live list, if present. */
+static void
+unlink_conn(pg_prop_conn *c)
+{
+	pg_prop_conn **pp = &live_conns;
+
+	while (*pp != NULL)
+	{
+		if (*pp == c)
+		{
+			*pp = c->next;
+			return;
+		}
+		pp = &(*pp)->next;
+	}
+}
+
+void
+pg_prop_disconnect(pg_prop_conn *conn)
+{
+	if (conn == NULL)
+		return;
+	unlink_conn(conn);
+	PQfinish(conn->conn);
+	pg_free(conn);
+}
+
+void
+pg_prop_sql_cleanup(void)
+{
+	while (live_conns != NULL)
+	{
+		pg_prop_conn *c = live_conns;
+
+		live_conns = c->next;
+		PQfinish(c->conn);
+		pg_free(c);
+	}
+}
+
+char *
+pg_prop_sql_scalar(pg_prop_conn *conn, const char *sql, bool *failed, char **errmsg)
+{
+	PGresult   *res = PQexec(conn->conn, sql);
+	ExecStatusType st;
+	char	   *out;
+
+	check_backend_alive(conn, sql);
+	st = PQresultStatus(res);
+
+	if (failed)
+		*failed = false;
+	if (st != PGRES_TUPLES_OK)
+	{
+		if (failed)
+			*failed = true;
+		if (errmsg)
+			*errmsg = pg_strdup(PQresultErrorMessage(res));
+		PQclear(res);
+		return NULL;
+	}
+	if (PQntuples(res) == 0 || PQnfields(res) == 0 || PQgetisnull(res, 0, 0))
+	{
+		PQclear(res);
+		return NULL;			/* genuine NULL / empty, *failed stays false */
+	}
+	out = pg_strdup(PQgetvalue(res, 0, 0));
+	PQclear(res);
+	return out;
+}
+
+char *
+pg_prop_sql_rows(pg_prop_conn *conn, const char *sql, bool *failed, char **errmsg)
+{
+	PGresult   *res = PQexec(conn->conn, sql);
+	ExecStatusType st;
+	StringInfoData s;
+	int			nrows,
+				ncols;
+
+	check_backend_alive(conn, sql);
+	st = PQresultStatus(res);
+
+	if (failed)
+		*failed = false;
+	if (st != PGRES_TUPLES_OK)
+	{
+		if (failed)
+			*failed = true;
+		if (errmsg)
+			*errmsg = pg_strdup(PQresultErrorMessage(res));
+		PQclear(res);
+		return NULL;
+	}
+
+	initStringInfo(&s);
+	nrows = PQntuples(res);
+	ncols = PQnfields(res);
+	for (int r = 0; r < nrows; r++)
+	{
+		for (int col = 0; col < ncols; col++)
+		{
+			if (col > 0)
+				appendStringInfoChar(&s, '\t');
+			if (PQgetisnull(res, r, col))
+				appendStringInfoString(&s, "\\N");
+			else
+				appendStringInfoString(&s, PQgetvalue(res, r, col));
+		}
+		appendStringInfoChar(&s, '\n');
+	}
+	PQclear(res);
+	return s.data;				/* caller frees */
+}
+
+/*
+ * Shared body for the two rowhash variants.  Digests the whole result
+ * server-side: md5 of the newline-joined per-row text.  Returning a 32-char
+ * hash instead of the full result keeps client memory O(1) regardless of
+ * result size (the deep generators can produce results that overflow the
+ * 1 GB StringInfo cap when rendered client-side; see finding 027).  When
+ * "ordered" is false the per-row text is sorted first, so the digest is of
+ * the row multiset rather than the row sequence.  An empty result hashes
+ * md5('') so two empty results compare equal.
+ */
+static char *
+rowhash_body(pg_prop_conn *conn, const char *sql, bool ordered,
+			 bool *failed, char **errmsg)
+{
+	StringInfoData q;
+	PGresult   *res;
+	ExecStatusType st;
+	char	   *out;
+
+	if (failed)
+		*failed = false;
+
+	initStringInfo(&q);
+	if (ordered)
+		appendStringInfo(&q,
+						 "SELECT md5(coalesce(string_agg(q.r, E'\\n'), '')) "
+						 "FROM (SELECT (sub)::text AS r FROM (%s) sub) q",
+						 sql);
+	else
+		appendStringInfo(&q,
+						 "SELECT md5(coalesce(string_agg(q.r, E'\\n' ORDER BY q.r), '')) "
+						 "FROM (SELECT (sub)::text AS r FROM (%s) sub) q",
+						 sql);
+	res = PQexec(conn->conn, q.data);
+	check_backend_alive(conn, sql);
+	pfree(q.data);
+	st = PQresultStatus(res);
+	if (st != PGRES_TUPLES_OK)
+	{
+		if (failed)
+			*failed = true;
+		if (errmsg)
+			*errmsg = pg_strdup(PQresultErrorMessage(res));
+		PQclear(res);
+		return NULL;
+	}
+	if (PQntuples(res) == 0 || PQgetisnull(res, 0, 0))
+	{
+		PQclear(res);
+		return pg_strdup("");	/* no rows -> empty digest */
+	}
+	out = pg_strdup(PQgetvalue(res, 0, 0));
+	PQclear(res);
+	return out;
+}
+
+char *
+pg_prop_sql_rowhash(pg_prop_conn *conn, const char *sql, bool *failed,
+					char **errmsg)
+{
+	return rowhash_body(conn, sql, true, failed, errmsg);
+}
+
+char *
+pg_prop_sql_rowhash_unordered(pg_prop_conn *conn, const char *sql,
+							  bool *failed, char **errmsg)
+{
+	return rowhash_body(conn, sql, false, failed, errmsg);
+}
+
+bool
+pg_prop_sql_exec(pg_prop_conn *conn, const char *sql, char **errmsg)
+{
+	PGresult   *res = PQexec(conn->conn, sql);
+	ExecStatusType st;
+
+	check_backend_alive(conn, sql);
+	st = PQresultStatus(res);
+
+	if (st != PGRES_COMMAND_OK && st != PGRES_TUPLES_OK)
+	{
+		if (errmsg)
+			*errmsg = pg_strdup(PQresultErrorMessage(res));
+		PQclear(res);
+		return false;
+	}
+	PQclear(res);
+	return true;
+}
+
+/*
+ * Load an extension (contrib module or procedural language) into the session,
+ * returning true if it is now available.  Checks pg_available_extensions first
+ * so a missing module is reported as "unavailable" (false) rather than a hard
+ * SQL error; if present, runs CREATE EXTENSION IF NOT EXISTS.  Used by areas
+ * that test an extension so they can pg_prop_skip() cleanly when it is not
+ * installed on the server under test.
+ */
+bool
+pg_prop_require_extension(pg_prop_conn *conn, const char *extname)
+{
+	PGresult   *res;
+	char		q[256];
+	bool		avail;
+
+	/* is it installable here at all? */
+	snprintf(q, sizeof(q),
+			 "SELECT 1 FROM pg_available_extensions WHERE name = '%s'",
+			 extname);
+	res = PQexec(conn->conn, q);
+	avail = (PQresultStatus(res) == PGRES_TUPLES_OK && PQntuples(res) == 1);
+	PQclear(res);
+	if (!avail)
+		return false;
+
+	snprintf(q, sizeof(q), "CREATE EXTENSION IF NOT EXISTS \"%s\"", extname);
+	res = PQexec(conn->conn, q);
+	avail = (PQresultStatus(res) == PGRES_COMMAND_OK ||
+			 PQresultStatus(res) == PGRES_TUPLES_OK);
+	PQclear(res);
+	return avail;
+}
+
+/*
+ * Asynchronous statement support, for properties that need two sessions in
+ * flight at once (e.g. provoking a deadlock, where both must be blocked before
+ * either can resolve).  pg_prop_sql_send dispatches a statement without waiting
+ * for its result; pg_prop_sql_await then blocks for the result and reports
+ * whether it succeeded, returning the SQLSTATE (a malloc'd 5-char code, or NULL)
+ * via *sqlstate so the caller can distinguish, e.g., a deadlock (40P01) from a
+ * serialization failure (40001).
+ */
+bool
+pg_prop_sql_send(pg_prop_conn *conn, const char *sql)
+{
+	return PQsendQuery(conn->conn, sql) == 1;
+}
+
+bool
+pg_prop_sql_await(pg_prop_conn *conn, char **sqlstate, char **errmsg)
+{
+	PGresult   *res;
+	bool		ok = true;
+
+	if (sqlstate)
+		*sqlstate = NULL;
+	if (errmsg)
+		*errmsg = NULL;
+
+	/* drain all results for the dispatched command(s) */
+	while ((res = PQgetResult(conn->conn)) != NULL)
+	{
+		ExecStatusType st = PQresultStatus(res);
+
+		if (st != PGRES_COMMAND_OK && st != PGRES_TUPLES_OK &&
+			st != PGRES_EMPTY_QUERY)
+		{
+			ok = false;
+			if (sqlstate && *sqlstate == NULL)
+			{
+				char	   *s = PQresultErrorField(res, PG_DIAG_SQLSTATE);
+
+				if (s)
+					*sqlstate = pg_strdup(s);
+			}
+			if (errmsg && *errmsg == NULL)
+				*errmsg = pg_strdup(PQresultErrorMessage(res));
+		}
+		PQclear(res);
+	}
+	return ok;
+}
diff --git a/src/test/prop/prop_binaryheap.c b/src/test/prop/prop_binaryheap.c
new file mode 100644
index 00000000000..af3d83d5b51
--- /dev/null
+++ b/src/test/prop/prop_binaryheap.c
@@ -0,0 +1,165 @@
+/*-------------------------------------------------------------------------
+ *
+ * prop_binaryheap.c
+ *	  Property-based tests for src/common/binaryheap.c.
+ *
+ * binaryheap is the shared binary heap used by merge append, the executor's
+ * bounded sorts, and more.  A heap has a crisp model: a priority queue.  The
+ * highest-value first test for any data structure is a model test -- drive
+ * the structure and a trusted reference (here, a sorted array) through the
+ * same operations and require they agree.  These tests do exactly that for
+ * the add / first / remove_first / build operations.
+ *
+ * Tier A: in-process, no server needed.
+ *
+ * Copyright (c) 2026, PostgreSQL Global Development Group
+ *
+ * IDENTIFICATION
+ *	  src/test/prop/prop_binaryheap.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres_fe.h"
+
+#define PG_PROP_AREA "binaryheap"
+#include "pg_prop.h"
+
+#include "lib/binaryheap.h"
+
+/*
+ * In frontend builds bh_node_type is void *; we store int64 values cast
+ * through intptr_t.  A min-heap comparator: returns >0 when a should come out
+ * before b (binaryheap is a max-heap on the comparator, so to pop ascending
+ * we make "smaller is greater").
+ */
+static int
+cmp_min(bh_node_type a, bh_node_type b, void *arg)
+{
+	int64		x = (int64) (intptr_t) a;
+	int64		y = (int64) (intptr_t) b;
+
+	/* max-heap pops the comparator-largest first; invert for ascending pops */
+	if (x < y)
+		return 1;
+	if (x > y)
+		return -1;
+	return 0;
+}
+
+static int
+cmp_i64(const void *a, const void *b)
+{
+	int64		x = *(const int64 *) a;
+	int64		y = *(const int64 *) b;
+
+	return (x > y) - (x < y);
+}
+
+/*
+ * Draw n values into a caller-provided array, bounded so they fit in intptr_t
+ * on any platform (use int32 range, widened to int64).
+ */
+static int
+draw_values(pg_prop_tc *tc, int64 *vals, int maxn)
+{
+	int			n = (int) pg_prop_draw_int(tc, 0, maxn);
+
+	for (int i = 0; i < n; i++)
+		vals[i] = pg_prop_draw_int(tc, INT32_MIN, INT32_MAX);
+	return n;
+}
+
+PG_PROP(pop_order_is_sorted)
+{
+	int64		vals[256];
+	int64		sorted[256];
+	int			n = draw_values(tc, vals, 200);
+	binaryheap *heap = binaryheap_allocate(n > 0 ? n : 1, cmp_min, NULL);
+
+	/* add every value, then pop them all: the pops must come out ascending */
+	for (int i = 0; i < n; i++)
+		binaryheap_add(heap, (bh_node_type) (intptr_t) vals[i]);
+
+	memcpy(sorted, vals, sizeof(int64) * n);
+	qsort(sorted, n, sizeof(int64), cmp_i64);
+
+	for (int i = 0; i < n; i++)
+	{
+		int64		got;
+
+		if (binaryheap_empty(heap))
+			pg_prop_fail(tc, "heap empty after %d of %d pops", i, n);
+		got = (int64) (intptr_t) binaryheap_remove_first(heap);
+		if (got != sorted[i])
+			pg_prop_fail(tc, "pop %d: got %lld, expected %lld (n=%d)",
+						 i, (long long) got, (long long) sorted[i], n);
+	}
+	if (!binaryheap_empty(heap))
+		pg_prop_fail(tc, "heap not empty after %d pops", n);
+
+	binaryheap_free(heap);
+}
+
+PG_PROP(first_is_minimum)
+{
+	int64		vals[256];
+	int			n;
+	binaryheap *heap;
+	int64		expected_min = 0;
+
+	/* need at least one element for binaryheap_first */
+	n = (int) pg_prop_draw_int(tc, 1, 200);
+	vals[0] = 0;				/* silence -Wmaybe-uninitialized; n >= 1 fills it */
+	for (int i = 0; i < n; i++)
+		vals[i] = pg_prop_draw_int(tc, INT32_MIN, INT32_MAX);
+
+	heap = binaryheap_allocate(n, cmp_min, NULL);
+	for (int i = 0; i < n; i++)
+		binaryheap_add(heap, (bh_node_type) (intptr_t) vals[i]);
+
+	expected_min = vals[0];
+	for (int i = 1; i < n; i++)
+		if (vals[i] < expected_min)
+			expected_min = vals[i];
+
+	{
+		int64		first = (int64) (intptr_t) binaryheap_first(heap);
+
+		if (first != expected_min)
+			pg_prop_fail(tc, "first = %lld, expected min %lld (n=%d)",
+						 (long long) first, (long long) expected_min, n);
+	}
+	binaryheap_free(heap);
+}
+
+PG_PROP(build_matches_incremental_add)
+{
+	int64		vals[256];
+	int			n = draw_values(tc, vals, 200);
+	binaryheap *h1 = binaryheap_allocate(n > 0 ? n : 1, cmp_min, NULL);
+	binaryheap *h2 = binaryheap_allocate(n > 0 ? n : 1, cmp_min, NULL);
+
+	/*
+	 * Two ways to construct the same heap: incremental add (sift-up each), and
+	 * add_unordered followed by binaryheap_build (Floyd's bulk heapify).  They
+	 * must pop in the same (sorted) order.
+	 */
+	for (int i = 0; i < n; i++)
+		binaryheap_add(h1, (bh_node_type) (intptr_t) vals[i]);
+
+	for (int i = 0; i < n; i++)
+		binaryheap_add_unordered(h2, (bh_node_type) (intptr_t) vals[i]);
+	binaryheap_build(h2);
+
+	for (int i = 0; i < n; i++)
+	{
+		int64		a = (int64) (intptr_t) binaryheap_remove_first(h1);
+		int64		b = (int64) (intptr_t) binaryheap_remove_first(h2);
+
+		if (a != b)
+			pg_prop_fail(tc, "pop %d: incremental=%lld build=%lld (n=%d)",
+						 i, (long long) a, (long long) b, n);
+	}
+	binaryheap_free(h1);
+	binaryheap_free(h2);
+}
diff --git a/src/test/prop/prop_nbtree.c b/src/test/prop/prop_nbtree.c
new file mode 100644
index 00000000000..b3fcf41416b
--- /dev/null
+++ b/src/test/prop/prop_nbtree.c
@@ -0,0 +1,227 @@
+/*-------------------------------------------------------------------------
+ *
+ * prop_nbtree.c
+ *	  Property-based tests for nbtree index scans (src/backend/access/nbtree).
+ *
+ * Target #2 in the design.  The skip-scan work (92fe23d93aa and the ~50
+ * follow-up fixes: "premature array advancement", "NULL tuple values always
+ * advance skip arrays", "commute strategy for DESC keys", row-compare
+ * robustness) makes nbtree scan-key handling a dense bug surface.
+ *
+ * The oracle is differential: the same query answered by a forced index scan
+ * and by a forced sequential scan must return the same rows.  The index is
+ * randomly shaped (column count, ASC/DESC, NULLS FIRST/LAST) and the query
+ * mixes equality, inequality, IN, and IS NULL, which is what drives skip
+ * arrays and array-key preprocessing.  Backward scans are exercised by a
+ * random ORDER BY direction.
+ *
+ * Tier B: runs SQL against the cluster the TAP driver started; see pg_prop.h
+ * and t/002_sql_props.pl.
+ *
+ * Copyright (c) 2026, PostgreSQL Global Development Group
+ *
+ * IDENTIFICATION
+ *	  src/test/prop/prop_nbtree.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres_fe.h"
+
+#define PG_PROP_AREA "nbtree"
+#include "pg_prop.h"
+
+#include "lib/stringinfo.h"
+
+#define VAL_MIN (-5)
+#define VAL_MAX 5
+#define NCOLS 3					/* columns a, b, c */
+
+static const char *const colnames[NCOLS] = {"a", "b", "c"};
+
+/* Draw an integer in [VAL_MIN,VAL_MAX] or NULL (~1 in 7). */
+static void
+draw_val(pg_prop_tc *tc, StringInfo buf)
+{
+	if (pg_prop_draw_int(tc, 0, 6) == 0)
+		appendStringInfoString(buf, "NULL");
+	else
+		appendStringInfo(buf, "%d", (int) pg_prop_draw_int(tc, VAL_MIN, VAL_MAX));
+}
+
+/*
+ * Create a table (a, b, c), a randomly shaped multi-column btree index over a
+ * prefix of its columns, and random rows.  Returns false (via assume) if the
+ * schema is rejected.
+ */
+static void
+build(pg_prop_tc *tc, pg_prop_conn *conn)
+{
+	StringInfoData ddl;
+	char	   *err = NULL;
+	int			ncols;
+	int			nrows;
+
+	initStringInfo(&ddl);
+	appendStringInfoString(&ddl,
+						   "DROP TABLE IF EXISTS nb_t; "
+						   "CREATE TABLE nb_t (a int, b int, c int); ");
+
+	/* index over the first ncols columns, each ASC/DESC and NULLS first/last */
+	ncols = (int) pg_prop_draw_int(tc, 1, NCOLS);
+	appendStringInfoString(&ddl, "CREATE INDEX nb_i ON nb_t (");
+	for (int i = 0; i < ncols; i++)
+	{
+		if (i > 0)
+			appendStringInfoString(&ddl, ", ");
+		appendStringInfo(&ddl, "%s %s NULLS %s", colnames[i],
+						 pg_prop_draw_bool(tc) ? "ASC" : "DESC",
+						 pg_prop_draw_bool(tc) ? "FIRST" : "LAST");
+	}
+	appendStringInfoString(&ddl, "); ");
+
+	if (!pg_prop_sql_exec(conn, ddl.data, &err))
+	{
+		pg_prop_note(tc, "schema rejected: %s", err ? err : "");
+		if (err)
+			pg_free(err);
+		pfree(ddl.data);
+		pg_prop_assume(tc, false);
+	}
+	pfree(ddl.data);
+
+	/* random rows */
+	{
+		StringInfoData ins;
+
+		nrows = (int) pg_prop_draw_int(tc, 0, 60);
+		initStringInfo(&ins);
+		for (int r = 0; r < nrows; r++)
+		{
+			appendStringInfoString(&ins, "INSERT INTO nb_t VALUES (");
+			for (int col = 0; col < NCOLS; col++)
+			{
+				if (col > 0)
+					appendStringInfoString(&ins, ", ");
+				draw_val(tc, &ins);
+			}
+			appendStringInfoString(&ins, "); ");
+		}
+		if (ins.len > 0 && !pg_prop_sql_exec(conn, ins.data, &err))
+		{
+			pg_prop_note(tc, "insert rejected: %s", err ? err : "");
+			if (err)
+				pg_free(err);
+			pfree(ins.data);
+			pg_prop_assume(tc, false);
+		}
+		pfree(ins.data);
+	}
+
+	/* keep the planner honest: tiny tables otherwise never use the index */
+	pg_prop_sql_exec(conn, "ANALYZE nb_t", &err);
+}
+
+/* Draw a predicate that leans on index scan-key machinery. */
+static void
+draw_predicate(pg_prop_tc *tc, StringInfo where)
+{
+	static const char *ops[] = {"=", "<", "<=", ">", ">=", "<>"};
+	int			nterms = (int) pg_prop_draw_int(tc, 1, 4);
+
+	for (int i = 0; i < nterms; i++)
+	{
+		const char *col = colnames[pg_prop_draw_int(tc, 0, NCOLS - 1)];
+		int			kind = (int) pg_prop_draw_int(tc, 0, 5);
+
+		if (i > 0)
+			appendStringInfoString(where, pg_prop_draw_bool(tc) ? " AND " : " OR ");
+
+		switch (kind)
+		{
+			case 0:
+				appendStringInfo(where, "%s IS NULL", col);
+				break;
+			case 1:
+				appendStringInfo(where, "%s IS NOT NULL", col);
+				break;
+			case 2:				/* = ANY, drives array keys / skip scan */
+				appendStringInfo(where, "%s = ANY (ARRAY[%d, %d, %d])", col,
+								 (int) pg_prop_draw_int(tc, VAL_MIN, VAL_MAX),
+								 (int) pg_prop_draw_int(tc, VAL_MIN, VAL_MAX),
+								 (int) pg_prop_draw_int(tc, VAL_MIN, VAL_MAX));
+				break;
+			case 3:				/* row comparison, (a,b) < (x,y) */
+				appendStringInfo(where, "(a, b) %s (%d, %d)",
+								 pg_prop_draw_bool(tc) ? "<" : ">",
+								 (int) pg_prop_draw_int(tc, VAL_MIN, VAL_MAX),
+								 (int) pg_prop_draw_int(tc, VAL_MIN, VAL_MAX));
+				break;
+			default:
+				appendStringInfo(where, "%s %s %d", col,
+								 ops[pg_prop_draw_int(tc, 0, lengthof(ops) - 1)],
+								 (int) pg_prop_draw_int(tc, VAL_MIN - 1, VAL_MAX + 1));
+				break;
+		}
+	}
+}
+
+PG_PROP(indexscan_matches_seqscan)
+{
+	pg_prop_conn *conn;
+	char	   *err = NULL;
+	StringInfoData where;
+	StringInfoData q;
+	const char *dir;
+	char	   *idx;
+	char	   *seq;
+	bool		f1,
+				f2;
+
+	conn = pg_prop_connect(&err);
+	if (conn == NULL)
+		pg_prop_fail(tc, "connect failed: %s", err ? err : "");
+
+	build(tc, conn);
+
+	initStringInfo(&where);
+	draw_predicate(tc, &where);
+
+	/* a random scan direction exercises backward index scans */
+	dir = pg_prop_draw_bool(tc) ? "ASC" : "DESC";
+
+	initStringInfo(&q);
+	appendStringInfo(&q,
+					 "SELECT a, b, c FROM nb_t WHERE %s "
+					 "ORDER BY a %s, b %s, c %s, (a IS NULL), (b IS NULL), (c IS NULL)",
+					 where.data, dir, dir, dir);
+
+	/* forced index scan */
+	pg_prop_sql_exec(conn,
+					 "SET enable_seqscan = off; SET enable_bitmapscan = off; "
+					 "SET enable_indexscan = on; SET enable_indexonlyscan = on",
+					 &err);
+	idx = pg_prop_sql_rows(conn, q.data, &f1, &err);
+
+	/* forced sequential scan: the trusted oracle */
+	pg_prop_sql_exec(conn,
+					 "SET enable_seqscan = on; SET enable_indexscan = off; "
+					 "SET enable_indexonlyscan = off; SET enable_bitmapscan = off",
+					 &err);
+	seq = pg_prop_sql_rows(conn, q.data, &f2, &err);
+
+	if (f1 || f2)
+		pg_prop_fail(tc, "query error on WHERE %s: %s", where.data,
+					 err ? err : "(unknown)");
+
+	if (strcmp(idx, seq) != 0)
+		pg_prop_fail(tc,
+					 "index scan != seq scan for WHERE %s (ORDER BY %s)\n"
+					 "  index: %s\n  seq:   %s",
+					 where.data, dir, idx, seq);
+
+	pg_free(idx);
+	pg_free(seq);
+	pfree(where.data);
+	pfree(q.data);
+	pg_prop_disconnect(conn);
+}
diff --git a/src/test/prop/prop_prng.c b/src/test/prop/prop_prng.c
new file mode 100644
index 00000000000..647c3aab822
--- /dev/null
+++ b/src/test/prop/prop_prng.c
@@ -0,0 +1,132 @@
+/*-------------------------------------------------------------------------
+ *
+ * prop_prng.c
+ *	  Property-based tests for src/common/pg_prng.c.
+ *
+ * pg_prng is the shared pseudo-random generator (xoroshiro128**) behind
+ * random(), sampling, and backend jitter.  Its range helpers carry explicit
+ * contracts -- a value in [rmin, rmax], a double in [0, 1) -- that must hold
+ * for every seed and every bound, including the degenerate rmin == rmax case
+ * and the full-width range.  Those contracts are exactly what a property test
+ * pins down.
+ *
+ * Tier A: in-process, no server needed.
+ *
+ * Copyright (c) 2026, PostgreSQL Global Development Group
+ *
+ * IDENTIFICATION
+ *	  src/test/prop/prop_prng.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres_fe.h"
+
+#define PG_PROP_AREA "prng"
+#include "pg_prop.h"
+
+#include "common/pg_prng.h"
+
+/*
+ * Seed a fresh generator from a drawn 64-bit value.  Seeding the PRNG from a
+ * hegel-drawn integer is the right pattern here (unlike feeding a seed to code
+ * that then makes its own random decisions): the property is about the range
+ * helpers' output bounds, which must hold for every internal state, so the
+ * seed is itself the input we want hegel to explore and shrink.
+ */
+static void
+seed_from_draw(pg_prop_tc *tc, pg_prng_state *state)
+{
+	uint64		s = (uint64) pg_prop_draw_int(tc, INT64_MIN, INT64_MAX);
+
+	pg_prng_seed(state, s);
+}
+
+PG_PROP(uint64_range_within_bounds)
+{
+	pg_prng_state state;
+	uint64		lo = (uint64) pg_prop_draw_int(tc, INT64_MIN, INT64_MAX);
+	uint64		hi = (uint64) pg_prop_draw_int(tc, INT64_MIN, INT64_MAX);
+	uint64		r;
+
+	if (lo > hi)
+	{
+		uint64		tmp = lo;
+
+		lo = hi;
+		hi = tmp;
+	}
+
+	seed_from_draw(tc, &state);
+
+	/* draw several values so one seed exercises more of the stream */
+	for (int i = 0; i < 20; i++)
+	{
+		r = pg_prng_uint64_range(&state, lo, hi);
+		if (r < lo || r > hi)
+			pg_prop_fail(tc,
+						 "uint64_range out of bounds: lo=%llu hi=%llu got=%llu",
+						 (unsigned long long) lo, (unsigned long long) hi,
+						 (unsigned long long) r);
+	}
+}
+
+PG_PROP(int64_range_within_bounds)
+{
+	pg_prng_state state;
+	int64		lo = pg_prop_draw_int(tc, INT64_MIN, INT64_MAX);
+	int64		hi = pg_prop_draw_int(tc, INT64_MIN, INT64_MAX);
+	int64		r;
+
+	if (lo > hi)
+	{
+		int64		tmp = lo;
+
+		lo = hi;
+		hi = tmp;
+	}
+
+	seed_from_draw(tc, &state);
+
+	for (int i = 0; i < 20; i++)
+	{
+		r = pg_prng_int64_range(&state, lo, hi);
+		if (r < lo || r > hi)
+			pg_prop_fail(tc,
+						 "int64_range out of bounds: lo=%lld hi=%lld got=%lld",
+						 (long long) lo, (long long) hi, (long long) r);
+	}
+}
+
+PG_PROP(singleton_range_is_exact)
+{
+	pg_prng_state state;
+	int64		v = pg_prop_draw_int(tc, INT64_MIN, INT64_MAX);
+
+	seed_from_draw(tc, &state);
+
+	/* a range of one value must always return that value */
+	for (int i = 0; i < 8; i++)
+	{
+		int64		r = pg_prng_int64_range(&state, v, v);
+
+		if (r != v)
+			pg_prop_fail(tc, "singleton range [%lld,%lld] returned %lld",
+						 (long long) v, (long long) v, (long long) r);
+	}
+}
+
+PG_PROP(double_in_unit_interval)
+{
+	pg_prng_state state;
+
+	seed_from_draw(tc, &state);
+
+	/* pg_prng_double() is documented to return a value in [0.0, 1.0) */
+	for (int i = 0; i < 50; i++)
+	{
+		double		d = pg_prng_double(&state);
+
+		if (!(d >= 0.0 && d < 1.0))
+			pg_prop_fail(tc, "double not in [0,1): got %.17g", d);
+	}
+}
diff --git a/src/test/prop/sql/prop_isolation.c b/src/test/prop/sql/prop_isolation.c
new file mode 100644
index 00000000000..30ce4ea5097
--- /dev/null
+++ b/src/test/prop/sql/prop_isolation.c
@@ -0,0 +1,432 @@
+/*-------------------------------------------------------------------------
+ *
+ * prop_isolation.c
+ *	  Property tests that transaction isolation guarantees hold.
+ *
+ * Isolation is one of the hardest things a database promises and one of the
+ * most important to get right.  These properties drive two concurrent sessions
+ * (two libpq connections) through interleavings and assert the guarantee each
+ * isolation level makes:
+ *
+ *	 ser_write_skew_prevented -- under SERIALIZABLE, the classic write-skew
+ *						        pair (each txn reads a set and writes based on
+ *						        it) cannot both commit; at least one must fail
+ *						        with a serialization error (SQLSTATE 40001).
+ *	 rr_snapshot_stable       -- under REPEATABLE READ, a txn's repeated read
+ *						        of a row returns the same value even after a
+ *						        concurrent committed update (snapshot stability).
+ *	 rc_sees_committed        -- under READ COMMITTED, a new statement sees a
+ *						        concurrently committed change (and does not see
+ *						        an uncommitted one).
+ *	 deadlock_one_victim      -- two txns that lock rows in opposite order
+ *						        deadlock, and exactly one is chosen as victim
+ *						        (SQLSTATE 40P01) while the other proceeds.
+ *
+ * The oracle is the documented guarantee, not a generated reference: a
+ * SERIALIZABLE anomaly committing, a REPEATABLE READ snapshot shifting, or a
+ * deadlock taking down both sessions would each be a serious isolation bug.
+ *
+ * Tier B: runs SQL against the cluster the TAP driver started.  Uses two
+ * connections; the harness's per-case cleanup closes any left open.
+ *
+ * Copyright (c) 2026, PostgreSQL Global Development Group
+ *
+ * IDENTIFICATION
+ *	  src/test/prop/sql/prop_isolation.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres_fe.h"
+
+#define PG_PROP_AREA "isolation"
+#include "pg_prop.h"
+
+#include "lib/stringinfo.h"
+
+/* Run SQL on a connection; return true on success, capturing sqlstate text. */
+static bool
+run(pg_prop_conn *conn, const char *sql, char **err)
+{
+	return pg_prop_sql_exec(conn, sql, err);
+}
+
+static pg_prop_conn *
+open_conn(pg_prop_tc *tc)
+{
+	char	   *err = NULL;
+	pg_prop_conn *c = pg_prop_connect(&err);
+
+	if (c == NULL)
+		pg_prop_fail(tc, "connect failed: %s", err ? err : "");
+	return c;
+}
+
+static long
+scalar_long(pg_prop_tc *tc, pg_prop_conn *conn, const char *sql)
+{
+	bool		failed = false;
+	char	   *err = NULL;
+	char	   *v;
+	long		n;
+
+	v = pg_prop_sql_scalar(conn, sql, &failed, &err);
+	if (failed)
+		pg_prop_fail(tc, "scalar failed: %s\n  SQL: %s",
+					 err ? err : "(unknown)", sql);
+	n = v ? atol(v) : 0;
+	if (v)
+		pg_free(v);
+	if (err)
+		pg_free(err);
+	return n;
+}
+
+/*
+ * Build iso_t(id int primary key, v int) with two rows (1,0),(2,0) on one
+ * connection.  Shared by the properties as the contended data.
+ */
+static void
+setup_iso(pg_prop_tc *tc, pg_prop_conn *admin)
+{
+	char	   *err = NULL;
+
+	if (!run(admin,
+			 "DROP TABLE IF EXISTS iso_t; "
+			 "CREATE TABLE iso_t (id int PRIMARY KEY, v int); "
+			 "INSERT INTO iso_t VALUES (1, 0), (2, 0);", &err))
+		pg_prop_fail(tc, "iso setup failed: %s", err ? err : "");
+}
+
+/*
+ * SERIALIZABLE write skew: T1 reads row 2 and sets row 1 = that value + 1;
+ * T2 reads row 1 and sets row 2 = that value + 1.  Serially either order gives
+ * a determinate result; concurrently this is the write-skew anomaly, and
+ * SERIALIZABLE must prevent both from committing.  At least one COMMIT must
+ * fail with a serialization error.
+ */
+PG_PROP(ser_write_skew_prevented)
+{
+	pg_prop_conn *admin = open_conn(tc);
+	pg_prop_conn *t1 = open_conn(tc);
+	pg_prop_conn *t2 = open_conn(tc);
+	char	   *e1 = NULL,
+			   *e2 = NULL,
+			   *err = NULL;
+	bool		c1,
+				c2;
+	int			a = (int) pg_prop_draw_int(tc, 0, 5);
+	int			b = (int) pg_prop_draw_int(tc, 0, 5);
+	StringInfoData q;
+
+	setup_iso(tc, admin);
+
+	run(t1, "BEGIN ISOLATION LEVEL SERIALIZABLE", &err);
+	run(t2, "BEGIN ISOLATION LEVEL SERIALIZABLE", &err);
+
+	/* each reads the "other" row, then writes "its" row based on it */
+	(void) scalar_long(tc, t1, "SELECT v FROM iso_t WHERE id = 2");
+	(void) scalar_long(tc, t2, "SELECT v FROM iso_t WHERE id = 1");
+
+	initStringInfo(&q);
+	appendStringInfo(&q, "UPDATE iso_t SET v = v + %d WHERE id = 1", a + 1);
+	run(t1, q.data, &err);
+	resetStringInfo(&q);
+	appendStringInfo(&q, "UPDATE iso_t SET v = v + %d WHERE id = 2", b + 1);
+	run(t2, q.data, &err);
+
+	c1 = run(t1, "COMMIT", &e1);
+	c2 = run(t2, "COMMIT", &e2);
+
+	/*
+	 * The guarantee: the two transactions form a dangerous read/write
+	 * dependency cycle, so they cannot both commit under SERIALIZABLE.  At
+	 * least one COMMIT must fail (SQLSTATE 40001, surfaced as a failed exec).
+	 */
+	if (c1 && c2)
+		pg_prop_fail(tc,
+					 "SERIALIZABLE allowed write skew: both transactions "
+					 "committed (a=%d b=%d)", a, b);
+
+	if (e1)
+		pg_free(e1);
+	if (e2)
+		pg_free(e2);
+	pfree(q.data);
+	pg_prop_disconnect(t1);
+	pg_prop_disconnect(t2);
+	pg_prop_disconnect(admin);
+}
+
+/*
+ * REPEATABLE READ snapshot stability: T1 reads row 1, then a concurrent
+ * committed T2 updates row 1, then T1 reads row 1 again.  T1 must see the same
+ * value both times (its snapshot is fixed at the first statement).
+ */
+PG_PROP(rr_snapshot_stable)
+{
+	pg_prop_conn *admin = open_conn(tc);
+	pg_prop_conn *t1 = open_conn(tc);
+	pg_prop_conn *t2 = open_conn(tc);
+	char	   *err = NULL;
+	long		first,
+				second;
+	int			delta = (int) pg_prop_draw_int(tc, 1, 100);
+	StringInfoData q;
+
+	setup_iso(tc, admin);
+
+	run(t1, "BEGIN ISOLATION LEVEL REPEATABLE READ", &err);
+	first = scalar_long(tc, t1, "SELECT v FROM iso_t WHERE id = 1");
+
+	/* concurrent committed update by T2 */
+	initStringInfo(&q);
+	appendStringInfo(&q, "UPDATE iso_t SET v = v + %d WHERE id = 1", delta);
+	run(t2, "BEGIN", &err);
+	run(t2, q.data, &err);
+	run(t2, "COMMIT", &err);
+
+	second = scalar_long(tc, t1, "SELECT v FROM iso_t WHERE id = 1");
+	run(t1, "COMMIT", &err);
+
+	if (first != second)
+		pg_prop_fail(tc,
+					 "REPEATABLE READ snapshot shifted: first=%ld second=%ld "
+					 "(concurrent +%d)", first, second, delta);
+
+	if (err)
+		pg_free(err);
+	pfree(q.data);
+	pg_prop_disconnect(t1);
+	pg_prop_disconnect(t2);
+	pg_prop_disconnect(admin);
+}
+
+/*
+ * READ COMMITTED visibility: within a READ COMMITTED transaction, a statement
+ * run after a concurrent commit sees the committed value, and a value written
+ * by an uncommitted concurrent txn is NOT seen.
+ */
+PG_PROP(rc_sees_committed)
+{
+	pg_prop_conn *admin = open_conn(tc);
+	pg_prop_conn *t1 = open_conn(tc);
+	pg_prop_conn *t2 = open_conn(tc);
+	char	   *err = NULL;
+	long		base,
+				after_uncommitted,
+				after_committed;
+	int			delta = (int) pg_prop_draw_int(tc, 1, 100);
+	StringInfoData q;
+
+	setup_iso(tc, admin);
+
+	run(t1, "BEGIN ISOLATION LEVEL READ COMMITTED", &err);
+	base = scalar_long(tc, t1, "SELECT v FROM iso_t WHERE id = 1");
+
+	/* T2 updates but does NOT commit yet */
+	initStringInfo(&q);
+	appendStringInfo(&q, "UPDATE iso_t SET v = v + %d WHERE id = 1", delta);
+	run(t2, "BEGIN", &err);
+	run(t2, q.data, &err);
+
+	/* T1's new statement must still see the base value (T2 uncommitted) */
+	after_uncommitted = scalar_long(tc, t1, "SELECT v FROM iso_t WHERE id = 1");
+	if (after_uncommitted != base)
+		pg_prop_fail(tc,
+					 "READ COMMITTED saw uncommitted write: base=%ld saw=%ld",
+					 base, after_uncommitted);
+
+	/* now T2 commits; T1's next statement must see the committed value */
+	run(t2, "COMMIT", &err);
+	after_committed = scalar_long(tc, t1, "SELECT v FROM iso_t WHERE id = 1");
+	if (after_committed != base + delta)
+		pg_prop_fail(tc,
+					 "READ COMMITTED did not see committed write: expected %ld saw %ld",
+					 base + delta, after_committed);
+
+	run(t1, "COMMIT", &err);
+	if (err)
+		pg_free(err);
+	pfree(q.data);
+	pg_prop_disconnect(t1);
+	pg_prop_disconnect(t2);
+	pg_prop_disconnect(admin);
+}
+
+/*
+ * SERIALIZABLE phantom / predicate-lock anomaly: T1 and T2 each count the rows
+ * matching a range predicate and then insert a new row that falls in the
+ * *other* transaction's predicate.  Serially one would see the other's insert;
+ * concurrently this is a write-skew-via-phantom, and SERIALIZABLE's predicate
+ * locking must stop both from committing.  At least one COMMIT must fail with a
+ * serialization error.
+ */
+PG_PROP(ser_phantom_prevented)
+{
+	pg_prop_conn *admin = open_conn(tc);
+	pg_prop_conn *t1 = open_conn(tc);
+	pg_prop_conn *t2 = open_conn(tc);
+	char	   *err = NULL,
+			   *e1 = NULL,
+			   *e2 = NULL;
+	bool		c1,
+				c2;
+
+	/* a table of integer "marks"; T1 works the even side, T2 the odd side */
+	if (!run(admin,
+			 "DROP TABLE IF EXISTS ph_t; "
+			 "CREATE TABLE ph_t (id serial PRIMARY KEY, g int); "
+			 "INSERT INTO ph_t (g) SELECT (i % 4) FROM generate_series(1, 8) i;",
+			 &err))
+		pg_prop_fail(tc, "phantom setup failed: %s", err ? err : "");
+
+	run(t1, "BEGIN ISOLATION LEVEL SERIALIZABLE", &err);
+	run(t2, "BEGIN ISOLATION LEVEL SERIALIZABLE", &err);
+
+	/* each reads a predicate range (establishing a predicate lock) ... */
+	(void) scalar_long(tc, t1, "SELECT count(*) FROM ph_t WHERE g = 0");
+	(void) scalar_long(tc, t2, "SELECT count(*) FROM ph_t WHERE g = 2");
+
+	/* ... then each inserts a phantom into the other's range */
+	run(t1, "INSERT INTO ph_t (g) VALUES (2)", &err);
+	run(t2, "INSERT INTO ph_t (g) VALUES (0)", &err);
+
+	c1 = run(t1, "COMMIT", &e1);
+	c2 = run(t2, "COMMIT", &e2);
+
+	if (c1 && c2)
+		pg_prop_fail(tc,
+					 "SERIALIZABLE allowed a phantom anomaly: both committed");
+
+	if (e1)
+		pg_free(e1);
+	if (e2)
+		pg_free(e2);
+	if (err)
+		pg_free(err);
+	pg_prop_disconnect(t1);
+	pg_prop_disconnect(t2);
+	pg_prop_disconnect(admin);
+}
+
+/*
+ * Lock conflicts are detected and bounded: a row locked by one session blocks
+ * a conflicting write from another, and with a short lock_timeout that write
+ * fails (SQLSTATE 55P03) rather than waiting forever.  This exercises the
+ * row-lock machinery synchronously (no second thread): T2 holds the lock, T1
+ * tries to take it with a timeout and must fail; after T2 commits, T1 can take
+ * it.  A write that neither waits nor fails would be a lock-correctness bug.
+ */
+PG_PROP(row_lock_conflict_times_out)
+{
+	pg_prop_conn *admin = open_conn(tc);
+	pg_prop_conn *t1 = open_conn(tc);
+	pg_prop_conn *t2 = open_conn(tc);
+	char	   *err = NULL;
+	bool		blocked_update_ok;
+	int			delta = (int) pg_prop_draw_int(tc, 1, 100);
+	StringInfoData q;
+
+	setup_iso(tc, admin);
+
+	/* T2 locks row 1 for update and holds it (open transaction) */
+	run(t2, "BEGIN", &err);
+	(void) scalar_long(tc, t2, "SELECT v FROM iso_t WHERE id = 1 FOR UPDATE");
+
+	/* T1, with a short lock_timeout, tries to update the same row: must fail */
+	run(t1, "BEGIN", &err);
+	run(t1, "SET lock_timeout = '300ms'", &err);
+	initStringInfo(&q);
+	appendStringInfo(&q, "UPDATE iso_t SET v = v + %d WHERE id = 1", delta);
+	blocked_update_ok = run(t1, q.data, &err);
+	if (blocked_update_ok)
+		pg_prop_fail(tc,
+					 "conflicting UPDATE succeeded while row was locked "
+					 "(expected lock_timeout)");
+	run(t1, "ROLLBACK", &err);
+
+	/* T2 releases; now T1 can take the lock and update */
+	run(t2, "COMMIT", &err);
+	run(t1, "BEGIN", &err);
+	run(t1, "SET lock_timeout = '5s'", &err);
+	if (!run(t1, q.data, &err))
+		pg_prop_fail(tc, "UPDATE failed after lock released: %s",
+					 err ? err : "");
+	run(t1, "COMMIT", &err);
+
+	if (err)
+		pg_free(err);
+	pfree(q.data);
+	pg_prop_disconnect(t1);
+	pg_prop_disconnect(t2);
+	pg_prop_disconnect(admin);
+}
+
+/*
+ * Deadlock detection and victim selection: two sessions lock rows in opposite
+ * order, so each ends up waiting on a lock the other holds -- a true deadlock.
+ * PostgreSQL's deadlock detector must break it by aborting exactly ONE session
+ * (SQLSTATE 40P01) while the other proceeds to commit.  This requires genuine
+ * concurrency: both UPDATEs must be in flight at once, so they are dispatched
+ * asynchronously (pg_prop_sql_send) and then awaited.  A deadlock that hangs
+ * both, or aborts both, or aborts neither, is a detector bug.
+ */
+PG_PROP(deadlock_one_victim)
+{
+	pg_prop_conn *admin = open_conn(tc);
+	pg_prop_conn *t1 = open_conn(tc);
+	pg_prop_conn *t2 = open_conn(tc);
+	char	   *err = NULL,
+			   *ss1 = NULL,
+			   *ss2 = NULL;
+	bool		ok1,
+				ok2;
+	int			victims;
+
+	setup_iso(tc, admin);
+
+	run(t1, "BEGIN", &err);
+	run(t2, "BEGIN", &err);
+
+	/* T1 locks row 1; T2 locks row 2 (no conflict yet) */
+	(void) scalar_long(tc, t1, "SELECT v FROM iso_t WHERE id = 1 FOR UPDATE");
+	(void) scalar_long(tc, t2, "SELECT v FROM iso_t WHERE id = 2 FOR UPDATE");
+
+	/*
+	 * Now each asks for the row the other holds, both in flight at once.
+	 * Dispatch both async so they are simultaneously blocked; the deadlock
+	 * detector then fires.
+	 */
+	if (!pg_prop_sql_send(t1, "UPDATE iso_t SET v = v + 1 WHERE id = 2"))
+		pg_prop_fail(tc, "could not dispatch T1 update");
+	if (!pg_prop_sql_send(t2, "UPDATE iso_t SET v = v + 1 WHERE id = 1"))
+		pg_prop_fail(tc, "could not dispatch T2 update");
+
+	/* await both; exactly one must fail with 40P01 (deadlock_detected) */
+	ok1 = pg_prop_sql_await(t1, &ss1, &err);
+	ok2 = pg_prop_sql_await(t2, &ss2, &err);
+
+	victims = 0;
+	if (!ok1 && ss1 && strcmp(ss1, "40P01") == 0)
+		victims++;
+	if (!ok2 && ss2 && strcmp(ss2, "40P01") == 0)
+		victims++;
+
+	/* the surviving session commits; the victim's transaction is already aborted */
+	run(ok1 ? t1 : t2, "COMMIT", &err);
+	run(ok1 ? t1 : t2, "ROLLBACK", &err);	/* no-op if already committed */
+
+	if (victims != 1)
+		pg_prop_fail(tc,
+					 "deadlock did not select exactly one victim: "
+					 "t1 ok=%d ss=%s, t2 ok=%d ss=%s (victims=%d)",
+					 (int) ok1, ss1 ? ss1 : "-", (int) ok2, ss2 ? ss2 : "-",
+					 victims);
+
+	if (ss1) pg_free(ss1);
+	if (ss2) pg_free(ss2);
+	if (err) pg_free(err);
+	pg_prop_disconnect(t1);
+	pg_prop_disconnect(t2);
+	pg_prop_disconnect(admin);
+}
diff --git a/src/test/prop/t/001_prop.pl b/src/test/prop/t/001_prop.pl
new file mode 100644
index 00000000000..e78db78abfb
--- /dev/null
+++ b/src/test/prop/t/001_prop.pl
@@ -0,0 +1,93 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+#
+# TAP driver for the property-based tests.  Runs each area of pg_prop and
+# reports one test point per area; pg_prop's own per-property output is echoed
+# as TAP diagnostics so a failure shows the counterexample and replay blob.
+#
+# Controls (all optional) come from the environment and are forwarded, so one
+# `meson test` run is reproducible and a buildfarm failure can be replayed:
+#   PG_PROP_ITERATIONS   test cases per property (default: pg_prop's own 100)
+#   PG_PROP_SEED         fixed RNG seed (default: pg_prop picks and prints one)
+#   PG_PROP_TESTS        whitespace-separated property names to run
+#
+# To debug a single property, run pg_prop directly, e.g.
+#   pg_prop path canonicalize_idempotent --iterations 100000 --seed 42
+# See README and DESIGN.
+
+use strict;
+use warnings FATAL => 'all';
+
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+# TAP driver for the in-process (Tier A) property tests.  These exercise
+# src/common and src/port code linked directly into pg_prop, so they need no
+# cluster.  The SQL-oracle (Tier B) areas run from t/002_sql_props.pl instead.
+#
+# Keep in step with the Tier-A entries in prop_area_sources (meson.build).
+my @areas = qw(binaryheap nbtree isolation prng);
+
+# No Tier-A areas wired in yet (e.g. the initial harness-only commit): nothing
+# to run, which is a pass, not a failure.
+if (!@areas)
+{
+	plan skip_all => 'no in-process property areas registered';
+}
+
+# Shared arguments from the environment.
+my @common_args = ('--no-tap');
+push @common_args, ('--iterations', $ENV{PG_PROP_ITERATIONS})
+  if defined $ENV{PG_PROP_ITERATIONS} && $ENV{PG_PROP_ITERATIONS} ne '';
+push @common_args, ('--seed', $ENV{PG_PROP_SEED})
+  if defined $ENV{PG_PROP_SEED} && $ENV{PG_PROP_SEED} ne '';
+
+# PG_PROP_TESTS selects individual properties by name (unqualified).
+my @select;
+@select = split /\s+/, $ENV{PG_PROP_TESTS}
+  if defined $ENV{PG_PROP_TESTS} && $ENV{PG_PROP_TESTS} ne '';
+
+# All areas are known to one binary; list once.
+my $list = '';
+run_log([ 'pg_prop', '--list' ], '>', \$list);
+my %known_in_area;
+for my $qualified (split /\n/, $list)
+{
+	my ($a, $n) = split m{/}, $qualified, 2;
+	push @{ $known_in_area{$a} }, $n if defined $n;
+}
+
+foreach my $area (@areas)
+{
+	my @args = @common_args;
+	if (@select)
+	{
+		my %have = map { $_ => 1 } @{ $known_in_area{$area} // [] };
+		my @mine = grep { $have{$_} } @select;
+		# A selection that this area has none of: skip it with a passing TODO
+		# so the plan still accounts for every area.
+	  SKIP:
+		{
+			skip "no selected properties in area $area", 1 unless @mine;
+			run_area($area, [ @args, @mine ]);
+		}
+		next;
+	}
+	run_area($area, \@args);
+}
+
+done_testing();
+
+# Run one area of pg_prop; emit one test point plus diagnostics.
+sub run_area
+{
+	my ($area, $args) = @_;
+	my ($stdout, $stderr) = ('', '');
+	my $ok = run_log([ 'pg_prop', $area, @$args ],
+		'>', \$stdout, '2>', \$stderr);
+
+	# Echo pg_prop's plain output as TAP diagnostics (note() prefixes "# ").
+	note($stdout) if $stdout ne '';
+	note($stderr) if $stderr ne '';
+
+	ok($ok, "pg_prop $area: all properties held");
+}
-- 
2.54.0

