From 4fd12e870c92d38fc9479089821808fc69bcb12c Mon Sep 17 00:00:00 2001
From: Andrew Dunstan <andrew@dunslane.net>
Date: Sat, 29 Aug 2026 11:00:02 -0400
Subject: [PATCH v9 1/1] Add amoptions callback to table access methods

Table AMs have had no way to define their own storage parameters:
CREATE/ALTER TABLE always parsed the WITH clause with the standard
heap parser, regardless of the table's actual AM.  Index AMs have had
this via IndexAmRoutine.amoptions for a long time; give table AMs
the same.

TableAmRoutine gets an optional amoptions field, same signature as
the index AM version. table_reloptions() dispatches to it when set,
else falls back to heap_reloptions().  An AM that supplies amoptions
owns the option set entirely; the bytea it returns is stored
verbatim in Relation->rd_options and dictates the layout its other
callbacks read.

SET ACCESS METHOD revalidates a relation's final reloptions against
the new AM once other subcommands in the same statement have run,
unconditionally -- even a new AM that falls back to the standard
heap parser may not accept an option the old AM did.  A reloption the
new AM rejects is an immediate error rather than a silent drop at
the next relcache load; RESET in the same statement clears it.  SET,
RESET and REPLACE subcommands queued alongside SET ACCESS METHOD are
not validated individually, so their order within the statement does
not matter.

Core code that reads StdRdOptions fields straight out of rd_options
(RelationGetFillFactor and friends, direct casts in vacuum.c and
index.c) checks RelationHasStdRdOptions() first, since an AM-owned
bytea isn't guaranteed to be StdRdOptions-shaped.  An AM opts back in
via TableAmRoutine.has_std_options_prefix when its own struct embeds
a full StdRdOptions, registering every such field or leaving it
zeroed instead of at its real default. vacuum_rel() has two such
guarded reads when handing a relation's storage parameters down to
its TOAST table.

add_reloption_to_kind(name, kind) lets an AM accept a
core-registered option (fillfactor, autovacuum_*, ...) without
redeclaring it.  It's also the only way an AM can affect autovacuum's
own scheduling, which always parses via the standard heap parser
regardless of AM.

src/test/modules/dummy_table_am demonstrates the API: SET ACCESS
METHOD revalidation in both directions, and every StdRdOptions field
registered and exercised, including toast_value_type on a table
with a toastable column.  A second AM in the same module,
dummy_custom_table_am, returns a struct of its own that is smaller
than StdRdOptions and leaves has_std_options_prefix unset, with each
field at the offset of a StdRdOptions field; its tests check that
TOAST table creation, VACUUM and SET ACCESS METHOD do not read
standard options out of it.

Discussion: https://www.postgresql.org/message-id/flat/ea1c4d33-0780-473c-96dc-1468cf733a04@dunslane.net
---
 doc/src/sgml/ref/alter_table.sgml             |  19 +
 doc/src/sgml/tableam.sgml                     |  92 +++++
 src/backend/access/common/reloptions.c        |  88 ++++-
 src/backend/catalog/index.c                   |   3 +-
 src/backend/commands/tablecmds.c              | 159 +++++++-
 src/backend/commands/vacuum.c                 |   5 +-
 src/backend/utils/cache/relcache.c            |   4 +-
 src/include/access/reloptions.h               |   3 +
 src/include/access/tableam.h                  |  60 +++
 src/include/utils/rel.h                       |  31 +-
 src/test/modules/Makefile                     |   1 +
 src/test/modules/dummy_table_am/Makefile      |  20 +
 src/test/modules/dummy_table_am/README        |  32 ++
 .../dummy_table_am/dummy_table_am--1.0.sql    |  21 +
 .../modules/dummy_table_am/dummy_table_am.c   | 368 ++++++++++++++++++
 .../dummy_table_am/dummy_table_am.control     |   5 +
 .../dummy_table_am/expected/custom_layout.out |  68 ++++
 .../dummy_table_am/expected/reloptions.out    | 299 ++++++++++++++
 src/test/modules/dummy_table_am/meson.build   |  34 ++
 .../dummy_table_am/sql/custom_layout.sql      |  45 +++
 .../modules/dummy_table_am/sql/reloptions.sql | 184 +++++++++
 src/test/modules/meson.build                  |   1 +
 22 files changed, 1528 insertions(+), 14 deletions(-)
 create mode 100644 src/test/modules/dummy_table_am/Makefile
 create mode 100644 src/test/modules/dummy_table_am/README
 create mode 100644 src/test/modules/dummy_table_am/dummy_table_am--1.0.sql
 create mode 100644 src/test/modules/dummy_table_am/dummy_table_am.c
 create mode 100644 src/test/modules/dummy_table_am/dummy_table_am.control
 create mode 100644 src/test/modules/dummy_table_am/expected/custom_layout.out
 create mode 100644 src/test/modules/dummy_table_am/expected/reloptions.out
 create mode 100644 src/test/modules/dummy_table_am/meson.build
 create mode 100644 src/test/modules/dummy_table_am/sql/custom_layout.sql
 create mode 100644 src/test/modules/dummy_table_am/sql/reloptions.sql

diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index 0f9d698d170..4f19fb8f656 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -807,6 +807,25 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
       causing future partitions to default to
       <varname>default_table_access_method</varname>.
      </para>
+     <para>
+      The new access method must accept every storage parameter
+      currently set on the table.  An access method may define its own
+      set of parameters, so a parameter that was legal under the old
+      access method is not necessarily recognized by the new one; if any
+      such parameter remains, <command>ALTER TABLE</command> raises an
+      error rather than silently dropping the value.  The unwanted
+      parameters can be cleared in the same statement, for example:
+<programlisting>
+ALTER TABLE measurement
+    SET ACCESS METHOD columnar,
+    RESET (fillfactor);
+</programlisting>
+      Validation is performed once, after all storage-parameter
+      sub-commands in the statement have been applied, so the order of
+      <literal>SET</literal>, <literal>RESET</literal>, and
+      <literal>SET ACCESS METHOD</literal> within the same
+      <command>ALTER TABLE</command> does not matter.
+     </para>
     </listitem>
    </varlistentry>
 
diff --git a/doc/src/sgml/tableam.sgml b/doc/src/sgml/tableam.sgml
index 9ccf5b739ed..68d8fd62f44 100644
--- a/doc/src/sgml/tableam.sgml
+++ b/doc/src/sgml/tableam.sgml
@@ -152,4 +152,96 @@ my_tableam_handler(PG_FUNCTION_ARGS)
   its implementation.
  </para>
 
+ <sect1 id="tableam-reloptions">
+  <title>Table Access Method Storage Parameters</title>
+
+  <para>
+   A table access method may define its own set of storage parameters
+   (reloptions) by supplying an <structfield>amoptions</structfield>
+   callback in its <structname>TableAmRoutine</structname>.  The callback
+   has the same signature as the corresponding index AM callback; it is
+   invoked at <command>CREATE TABLE</command> and
+   <command>ALTER TABLE</command> time to parse and validate the option
+   set, and at relation open time (with <literal>validate = false</literal>)
+   to build the in-memory representation stored in
+   <structfield>Relation-&gt;rd_options</structfield>.  An AM that does not
+   supply an <structfield>amoptions</structfield> callback inherits the
+   standard heap parser and the <structname>StdRdOptions</structname>
+   layout.
+  </para>
+
+  <para>
+   When the AM provides its own parser it owns the option set entirely:
+   it may accept all standard heap options, only a subset, or define
+   parameters of its own.  The bytea returned from the callback is
+   stored verbatim in <structfield>rd_options</structfield>, so the AM
+   also dictates the in-memory layout that its other callbacks read.
+  </para>
+
+  <para>
+   The parser is expected to validate user-supplied values, but
+   <emphasis>must not silently rewrite them</emphasis>.  In particular
+   it must not coerce out-of-range values to a default, drop unknown
+   options when <literal>validate = true</literal>, or substitute a
+   different unit; the user must be able to verify with
+   <command>SELECT reloptions FROM pg_class</command> that the values
+   they supplied are what the relation will use.  Out-of-range or
+   unknown options should be reported with
+   <function>ereport(ERROR)</function>.
+  </para>
+
+  <para>
+   To honour an option that the core code already registers for
+   <literal>RELOPT_KIND_HEAP</literal> (for example
+   <literal>fillfactor</literal> or the <literal>autovacuum_*</literal>
+   family), call <function>add_reloption_to_kind()</function> once per
+   option in the module's <function>_PG_init</function>.  This extends
+   the existing registration with the AM's own kind without forcing
+   the AM to re-declare each option.
+  </para>
+
+  <para>
+   The <literal>autovacuum_*</literal> family is a special case, and
+   reusing the standard names is the only way to reach autovacuum's own
+   scheduling logic with them at all: every field of
+   <structname>AutoVacOpts</structname> (whether autovacuum runs on the
+   table, its vacuum/analyze thresholds and scale factors, freeze ages,
+   cost delay and limit, log-duration settings, and so on) is extracted
+   by autovacuum's periodic scan of <structname>pg_class</structname>
+   using the standard heap parser unconditionally, never the relation's
+   own <structfield>amoptions</structfield> callback -- looking up and
+   calling the AM's own parser for every relation on every autovacuum
+   cycle would add a catalog lookup to a hot path for no AM that
+   currently needs it.  This is independent of
+   <structfield>has_std_options_prefix</structfield>: it applies even to an
+   AM whose reloptions struct does not embed
+   <structname>StdRdOptions</structname> at all.  A table AM can only
+   affect autovacuum's own scheduling by exposing these standard
+   <literal>autovacuum_*</literal> names via
+   <function>add_reloption_to_kind()</function>; a differently-named
+   option of its own is stored and readable from
+   <structfield>Relation-&gt;rd_options</structfield> like any other
+   AM-specific option, but autovacuum's scheduling logic will never see
+   it.
+  </para>
+
+  <para>
+   <command>ALTER TABLE ... SET ACCESS METHOD</command> revalidates the
+   relation's current storage parameters against the new access
+   method's parser after all <literal>SET</literal>,
+   <literal>RESET</literal>, and <literal>REPLACE</literal>
+   sub-commands in the same statement have been applied.  A parameter
+   that is not accepted by the new AM raises an error; the user can
+   clear such parameters in the same statement (see <xref
+   linkend="sql-altertable"/>).
+  </para>
+
+  <para>
+   See <filename>src/test/modules/dummy_table_am</filename> for a
+   minimal example that exercises both
+   <structfield>amoptions</structfield> and
+   <function>add_reloption_to_kind()</function>.
+  </para>
+ </sect1>
+
 </chapter>
diff --git a/src/backend/access/common/reloptions.c b/src/backend/access/common/reloptions.c
index ea9a0417909..440b09a3794 100644
--- a/src/backend/access/common/reloptions.c
+++ b/src/backend/access/common/reloptions.c
@@ -24,6 +24,7 @@
 #include "access/nbtree.h"
 #include "access/reloptions.h"
 #include "access/spgist_private.h"
+#include "access/tableam.h"
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "commands/tablespace.h"
@@ -843,6 +844,44 @@ add_reloption_kind(void)
 	return (relopt_kind) last_assigned_kind;
 }
 
+/*
+ * add_reloption_to_kind
+ *		Extend an already-registered reloption so it is also accepted for
+ *		the given kind.
+ *
+ * Useful for table access methods that want their own RELOPT_KIND_*
+ * parser to accept standard options (fillfactor, parallel_workers,
+ * autovacuum_*, etc.) that core registers only for RELOPT_KIND_HEAP.
+ * Without this, every AM that wants the standard option set would
+ * have to re-register each option under its own kind.
+ *
+ * 'name' must match an existing option; 'kind' is OR'ed into that
+ * option's kinds mask.  Errors if no option with that name exists.
+ */
+void
+add_reloption_to_kind(const char *name, relopt_kind kind)
+{
+	int			namelen = strlen(name);
+	int			i;
+
+	if (need_initialization)
+		initialize_reloptions();
+
+	for (i = 0; relOpts[i]; i++)
+	{
+		if (relOpts[i]->namelen == namelen &&
+			strncmp(relOpts[i]->name, name, namelen) == 0)
+		{
+			relOpts[i]->kinds |= kind;
+			return;
+		}
+	}
+
+	ereport(ERROR,
+			(errcode(ERRCODE_UNDEFINED_OBJECT),
+			 errmsg("reloption \"%s\" does not exist", name)));
+}
+
 /*
  * add_reloption
  *		Add an already-created custom reloption to the list, and recompute the
@@ -1609,8 +1648,11 @@ extractRelOptions(HeapTuple tuple, TupleDesc tupdesc,
 	switch (classForm->relkind)
 	{
 		case RELKIND_RELATION:
-		case RELKIND_TOASTVALUE:
 		case RELKIND_MATVIEW:
+			options = table_reloptions(amoptions, classForm->relkind,
+									   datum, false);
+			break;
+		case RELKIND_TOASTVALUE:
 			options = heap_reloptions(classForm->relkind, datum, false);
 			break;
 		case RELKIND_PARTITIONED_TABLE:
@@ -2390,6 +2432,50 @@ heap_reloptions(char relkind, Datum reloptions, bool validate)
 	}
 }
 
+/*
+ * Parse options for a table relation, dispatching to the access method's
+ * own option parser when it supplies one.
+ *
+ *	amoptions	the table AM's option parser, or NULL to fall back to the
+ *				standard heap parser for this relkind.
+ *	relkind		the relation's kind.
+ *	reloptions	options as a text[] datum.
+ *	validate	error flag for unknown options or bad values.
+ *
+ * When amoptions is non-NULL the AM owns the option set: it may accept
+ * all standard heap options, only a subset, or define its own.  The
+ * returned bytea is laid out as the AM dictates (it is stored verbatim
+ * in Relation->rd_options).  When amoptions is NULL the result is the
+ * standard StdRdOptions layout.
+ */
+bytea *
+table_reloptions(amoptions_function amoptions, char relkind,
+				 Datum reloptions, bool validate)
+{
+	if (amoptions != NULL)
+		return amoptions(reloptions, validate);
+	return heap_reloptions(relkind, reloptions, validate);
+}
+
+/*
+ * Returns true when the relation's rd_options buffer is laid out as
+ * StdRdOptions.  The rel.h accessor macros (RelationGetFillFactor,
+ * RelationIsUsedAsCatalogTable, ...) check this first before casting
+ * rd_options to StdRdOptions, so that a table access method which supplies
+ * its own amoptions callback (and therefore owns the rd_options layout)
+ * does not have its bytes misinterpreted.
+ */
+bool
+RelationHasStdRdOptions(Relation relation)
+{
+	if (relation->rd_options == NULL)
+		return false;
+	if (relation->rd_tableam == NULL)
+		return false;
+	return relation->rd_tableam->amoptions == NULL ||
+		relation->rd_tableam->has_std_options_prefix;
+}
+
 
 /*
  * Parse options for indexes.
diff --git a/src/backend/catalog/index.c b/src/backend/catalog/index.c
index 4d232b85ad5..c2e4b959509 100644
--- a/src/backend/catalog/index.c
+++ b/src/backend/catalog/index.c
@@ -2972,7 +2972,8 @@ index_update_stats(Relation rel,
 	{
 		if (AutoVacuumingActive())
 		{
-			StdRdOptions *options = (StdRdOptions *) rel->rd_options;
+			StdRdOptions *options = RelationHasStdRdOptions(rel) ?
+				(StdRdOptions *) rel->rd_options : NULL;
 
 			if (options != NULL &&
 				options->autovacuum.enabled == PG_TERNARY_FALSE)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 0274d892f2e..e7e03bcfbdc 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -696,9 +696,11 @@ static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
 								const char *tablespacename, LOCKMODE lockmode);
 static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
 static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
+static void ATValidateAccessMethodOptions(List **wqueue);
 static void ATExecSetRelOptions(Relation rel, List *defList,
 								AlterTableType operation,
-								LOCKMODE lockmode);
+								LOCKMODE lockmode,
+								bool deferValidation);
 static void ATExecEnableDisableTrigger(Relation rel, const char *trigname,
 									   char fires_when, bool skip_system, bool recurse,
 									   LOCKMODE lockmode);
@@ -959,6 +961,49 @@ DefineRelation(CreateStmt *stmt, char relkind, Oid ownerId,
 		case RELKIND_PARTITIONED_TABLE:
 			(void) partitioned_table_reloptions(reloptions, true);
 			break;
+		case RELKIND_RELATION:
+		case RELKIND_MATVIEW:
+			{
+				amoptions_function amoptions = NULL;
+				Oid			amoid = InvalidOid;
+
+				/*
+				 * Resolve the table AM so its option parser can validate
+				 * AM-specific reloptions.  An AM that does not register a
+				 * parser falls back to default_reloptions for
+				 * RELOPT_KIND_HEAP.
+				 */
+				if (stmt->accessMethod != NULL)
+					amoid = get_table_am_oid(stmt->accessMethod, false);
+				else if (stmt->partbound != NULL && inheritOids != NIL)
+					amoid = get_rel_relam(linitial_oid(inheritOids));
+
+				/*
+				 * A partitioned parent may have no AM of its own (relam = 0);
+				 * fall back to default_table_access_method, matching the
+				 * resolution the actual relation creation below uses, so
+				 * options are validated by the same AM that will own the
+				 * relation.
+				 */
+				if (!OidIsValid(amoid))
+					amoid = get_table_am_oid(default_table_access_method, false);
+
+				if (OidIsValid(amoid))
+				{
+					HeapTuple	tuple;
+
+					tuple = SearchSysCache1(AMOID, ObjectIdGetDatum(amoid));
+					if (HeapTupleIsValid(tuple))
+					{
+						Form_pg_am	amform = (Form_pg_am) GETSTRUCT(tuple);
+
+						amoptions = GetTableAmRoutine(amform->amhandler)->amoptions;
+						ReleaseSysCache(tuple);
+					}
+				}
+				(void) table_reloptions(amoptions, relkind, reloptions, true);
+			}
+			break;
 		default:
 			(void) heap_reloptions(relkind, reloptions, true);
 	}
@@ -4947,6 +4992,18 @@ ATController(AlterTableStmt *parsetree,
 	/* Phase 2: update system catalogs */
 	ATRewriteCatalogs(&wqueue, lockmode, context);
 
+	/*
+	 * After all phase-2 subcommands have committed any SET / RESET / REPLACE
+	 * option changes to pg_class, but before any rewrite, ensure the final
+	 * reloptions are accepted by the access method the relation will use once
+	 * the ALTER TABLE finishes.  This catches the case where SET ACCESS
+	 * METHOD changes the AM and leaves pre-existing reloptions in pg_class
+	 * that the new AM does not recognise; without this check the new AM's
+	 * option parser would be called with validate=false at relcache load time
+	 * and silently ignore them.
+	 */
+	ATValidateAccessMethodOptions(&wqueue);
+
 	/* Phase 3: scan/rewrite tables as needed, and run afterStmts */
 	ATRewriteTables(parsetree, &wqueue, lockmode, context);
 }
@@ -5612,7 +5669,19 @@ ATExecCmd(List **wqueue, AlteredTableInfo *tab,
 		case AT_SetRelOptions:	/* SET (...) */
 		case AT_ResetRelOptions:	/* RESET (...) */
 		case AT_ReplaceRelOptions:	/* replace entire option list */
-			ATExecSetRelOptions(rel, (List *) cmd->def, cmd->subtype, lockmode);
+
+			/*
+			 * If SET ACCESS METHOD is queued in the same ALTER TABLE, the
+			 * reloptions in pg_class will be parsed by the new AM after the
+			 * statement finishes, and ATValidateAccessMethodOptions() checks
+			 * the final list against that AM once every subcommand has run.
+			 * Validating the intermediate list here would make the result
+			 * depend on subcommand order: in SET ACCESS METHOD x, SET (a =
+			 * 1), RESET (b), the SET would be checked while b, which x may
+			 * not accept, is still present.
+			 */
+			ATExecSetRelOptions(rel, (List *) cmd->def, cmd->subtype, lockmode,
+								tab->chgAccessMethod);
 			break;
 		case AT_EnableTrig:		/* ENABLE TRIGGER name */
 			ATExecEnableDisableTrigger(rel, cmd->name,
@@ -17304,12 +17373,90 @@ ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel, const char *tablespacen
 	tab->newTableSpace = tablespaceId;
 }
 
+/*
+ * Re-validate pg_class.reloptions for every work-queue entry whose access
+ * method is being changed.  Called between phase 2 (catalog updates) and
+ * phase 3 (table rewrites): SET / RESET / REPLACE subcommands have already
+ * been committed to pg_class, and tab->newAccessMethod identifies the AM
+ * the relation will use once the ALTER TABLE finishes.
+ *
+ * The check exists because relcache.c calls the AM's option parser with
+ * validate=false at relation open: any pre-existing reloption that the
+ * new AM does not recognise would otherwise be silently dropped from the
+ * parsed StdRdOptions / AM-specific options struct, leaving the user
+ * unable to tell that the option is no longer in effect.  Failing the
+ * ALTER TABLE here with a clear message lets the user RESET the option
+ * in the same statement and re-run.
+ */
+static void
+ATValidateAccessMethodOptions(List **wqueue)
+{
+	ListCell   *ltab;
+
+	foreach(ltab, *wqueue)
+	{
+		AlteredTableInfo *tab = (AlteredTableInfo *) lfirst(ltab);
+		HeapTuple	amtup;
+		HeapTuple	reltup;
+		Form_pg_am	amform;
+		Form_pg_class relform;
+		amoptions_function amoptions;
+		Datum		reloptions;
+		bool		isnull;
+		Oid			amoid;
+
+		if (!tab->chgAccessMethod)
+			continue;
+
+		/*
+		 * Partitioned tables may reset the AM to "default" (InvalidOid); each
+		 * partition then chooses its own AM at create time, so there is no
+		 * per-relation AM whose parser to consult here.
+		 */
+		amoid = tab->newAccessMethod;
+		if (!OidIsValid(amoid))
+			continue;
+
+		amtup = SearchSysCache1(AMOID, ObjectIdGetDatum(amoid));
+		if (!HeapTupleIsValid(amtup))
+			elog(ERROR, "cache lookup failed for access method %u", amoid);
+		amform = (Form_pg_am) GETSTRUCT(amtup);
+		amoptions = GetTableAmRoutine(amform->amhandler)->amoptions;
+		ReleaseSysCache(amtup);
+
+		/*
+		 * Validate unconditionally, even when the new AM has no option parser
+		 * of its own: table_reloptions() then falls back to the standard heap
+		 * parser, which is exactly what relcache.c will use to reinterpret
+		 * these bytes at the next open.  The old AM may have accepted options
+		 * heap doesn't know (e.g. a custom AM's own options), so "new AM
+		 * falls back to heap" is not itself a reason to skip the check.
+		 */
+		reltup = SearchSysCache1(RELOID, ObjectIdGetDatum(tab->relid));
+		if (!HeapTupleIsValid(reltup))
+			elog(ERROR, "cache lookup failed for relation %u", tab->relid);
+		relform = (Form_pg_class) GETSTRUCT(reltup);
+		reloptions = SysCacheGetAttr(RELOID, reltup,
+									 Anum_pg_class_reloptions, &isnull);
+		if (!isnull)
+			(void) table_reloptions(amoptions, relform->relkind,
+									reloptions, true);
+		ReleaseSysCache(reltup);
+	}
+}
+
 /*
  * Set, reset, or replace reloptions.
+ *
+ * If deferValidation is true, a table or materialized view's resulting
+ * reloptions are not validated here.  This is used when SET ACCESS METHOD
+ * is queued in the same ALTER TABLE: the options must then be accepted by
+ * the new AM rather than the current one, and ATValidateAccessMethodOptions()
+ * checks the final list after all subcommands have run.
  */
 static void
 ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
-					LOCKMODE lockmode)
+					LOCKMODE lockmode, bool deferValidation)
 {
 	Oid			relid;
 	Relation	pgclass;
@@ -17361,7 +17508,11 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
 	{
 		case RELKIND_RELATION:
 		case RELKIND_MATVIEW:
-			(void) heap_reloptions(rel->rd_rel->relkind, newOptions, true);
+			if (!deferValidation)
+				(void) table_reloptions(rel->rd_tableam ?
+										rel->rd_tableam->amoptions : NULL,
+										rel->rd_rel->relkind,
+										newOptions, true);
 			break;
 		case RELKIND_PARTITIONED_TABLE:
 			(void) partitioned_table_reloptions(newOptions, true);
diff --git a/src/backend/commands/vacuum.c b/src/backend/commands/vacuum.c
index d8c2f33c615..0d7897b4e44 100644
--- a/src/backend/commands/vacuum.c
+++ b/src/backend/commands/vacuum.c
@@ -2211,7 +2211,8 @@ vacuum_rel(Oid relid, RangeVar *relation, VacuumParams params,
 	 * whose parameters the caller handed down for that purpose.  For anything
 	 * else, params.main_relopts is NULL, and this just copies our own.
 	 */
-	relopts = merge_toast_reloptions((StdRdOptions *) rel->rd_options,
+	relopts = merge_toast_reloptions(RelationHasStdRdOptions(rel) ?
+									 (StdRdOptions *) rel->rd_options : NULL,
 									 params.main_relopts);
 
 	/*
@@ -2306,7 +2307,7 @@ vacuum_rel(Oid relid, RangeVar *relation, VacuumParams params,
 	 * a copy while we still have the relation open; the relcache entry can go
 	 * away once we close it.
 	 */
-	if (OidIsValid(toast_relid) && rel->rd_options)
+	if (OidIsValid(toast_relid) && RelationHasStdRdOptions(rel))
 	{
 		memcpy(&relopts_copy, rel->rd_options, sizeof(StdRdOptions));
 		toast_vacuum_params.main_relopts = &relopts_copy;
diff --git a/src/backend/utils/cache/relcache.c b/src/backend/utils/cache/relcache.c
index d8f04a05309..dcb298f61db 100644
--- a/src/backend/utils/cache/relcache.c
+++ b/src/backend/utils/cache/relcache.c
@@ -483,9 +483,11 @@ RelationParseRelOptions(Relation relation, HeapTuple tuple)
 	switch (relation->rd_rel->relkind)
 	{
 		case RELKIND_RELATION:
+		case RELKIND_MATVIEW:
+			amoptsfn = relation->rd_tableam ? relation->rd_tableam->amoptions : NULL;
+			break;
 		case RELKIND_TOASTVALUE:
 		case RELKIND_VIEW:
-		case RELKIND_MATVIEW:
 		case RELKIND_PARTITIONED_TABLE:
 			amoptsfn = NULL;
 			break;
diff --git a/src/include/access/reloptions.h b/src/include/access/reloptions.h
index ccff4717b62..dc07f78c470 100644
--- a/src/include/access/reloptions.h
+++ b/src/include/access/reloptions.h
@@ -187,6 +187,7 @@ typedef struct local_relopts
 	 (char *)(optstruct) + (optstruct)->member)
 
 extern relopt_kind add_reloption_kind(void);
+extern void add_reloption_to_kind(const char *name, relopt_kind kind);
 extern void add_bool_reloption(uint32 kinds, const char *name, const char *desc,
 							   bool default_val, LOCKMODE lockmode);
 extern void add_ternary_reloption(uint32 kinds, const char *name,
@@ -250,6 +251,8 @@ extern bytea *default_reloptions(Datum reloptions, bool validate,
 extern struct StdRdOptions *merge_toast_reloptions(const struct StdRdOptions *toast_opts,
 												   const struct StdRdOptions *main_opts);
 extern bytea *heap_reloptions(char relkind, Datum reloptions, bool validate);
+extern bytea *table_reloptions(amoptions_function amoptions, char relkind,
+							   Datum reloptions, bool validate);
 extern bytea *view_reloptions(Datum reloptions, bool validate);
 extern bytea *partitioned_table_reloptions(Datum reloptions, bool validate);
 extern bytea *index_reloptions(amoptions_function amoptions, Datum reloptions,
diff --git a/src/include/access/tableam.h b/src/include/access/tableam.h
index ea3f2a6be99..6dfc6e8026c 100644
--- a/src/include/access/tableam.h
+++ b/src/include/access/tableam.h
@@ -17,6 +17,7 @@
 #ifndef TABLEAM_H
 #define TABLEAM_H
 
+#include "access/amapi.h"
 #include "access/relscan.h"
 #include "access/sdir.h"
 #include "access/xact.h"
@@ -325,6 +326,65 @@ typedef struct TableAmRoutine
 	NodeTag		type;
 
 
+	/* ------------------------------------------------------------------------
+	 * Reloption parsing.
+	 * ------------------------------------------------------------------------
+	 */
+
+	/*
+	 * Parse and validate AM-specific reloptions.  Optional: when NULL, the
+	 * caller falls back to the standard heap reloption parser
+	 * (default_reloptions with RELOPT_KIND_HEAP) and the result is laid out
+	 * as StdRdOptions.
+	 *
+	 * When non-NULL, the AM owns the option set entirely.  It is free to
+	 * accept all standard heap options, only a subset, or to add its own. The
+	 * returned bytea must begin with a VARSIZE header and is stored in
+	 * Relation->rd_options, so the AM dictates the in-memory layout that its
+	 * other callbacks read.  Core code that reads StdRdOptions fields out of
+	 * rd_options (RelationGetFillFactor, RelationIsUsedAsCatalogTable, ...)
+	 * checks RelationHasStdRdOptions() first, so a custom layout will not be
+	 * misinterpreted -- unless the AM sets has_std_options_prefix below to
+	 * declare that its struct is a StdRdOptions superset after all.
+	 *
+	 * The callback validates user-supplied values but must not silently
+	 * rewrite them: a user inspecting pg_class.reloptions must see exactly
+	 * what they passed in.  Out-of-range or unknown options should be
+	 * reported with ereport(ERROR) when validate is true.
+	 *
+	 * Signature matches the index AM's amoptions callback so the same helper
+	 * machinery (add_string_reloption, add_int_reloption, etc.) can be used.
+	 */
+	amoptions_function amoptions;
+
+	/*
+	 * Set to true when amoptions is non-NULL and the bytea it returns begins
+	 * with a full "StdRdOptions std;" as its first member (i.e. the AM's own
+	 * reloptions struct is a superset of StdRdOptions, not just a layout that
+	 * happens to share a prefix).  This tells core code that it is safe to
+	 * read StdRdOptions fields directly out of rd_options
+	 * (RelationGetFillFactor, RelationIsUsedAsCatalogTable, ...) for
+	 * relations of this AM, exactly as it would for plain heap.
+	 *
+	 * An AM that sets this must register every StdRdOptions field those
+	 * macros read (fillfactor, toast_tuple_target, toast_value_type,
+	 * user_catalog_table, parallel_workers, vacuum_index_cleanup,
+	 * vacuum_truncate, vacuum_max_eager_freeze_failure_rate,
+	 * autovacuum_enabled) with add_reloption_to_kind(), even if it exposes
+	 * none of them as options the AM cares about itself: build_reloptions()
+	 * only fills in fields that are registered for the AM's relopt_kind, so
+	 * an embedded StdRdOptions field the AM never registers is left zeroed
+	 * rather than at that option's real default (0 is not a valid "unset"
+	 * sentinel for several of these fields, e.g. parallel_workers and
+	 * vacuum_max_eager_freeze_failure_rate both use -1).  Registering the
+	 * field via add_reloption_to_kind lets each one pick up its normal
+	 * catalog default instead.
+	 *
+	 * Ignored when amoptions is NULL.
+	 */
+	bool		has_std_options_prefix;
+
+
 	/* ------------------------------------------------------------------------
 	 * Slot related callbacks.
 	 * ------------------------------------------------------------------------
diff --git a/src/include/utils/rel.h b/src/include/utils/rel.h
index 7b8c2b1e362..dfd3af7959c 100644
--- a/src/include/utils/rel.h
+++ b/src/include/utils/rel.h
@@ -381,12 +381,33 @@ typedef struct StdRdOptions
 #define HEAP_MIN_FILLFACTOR			10
 #define HEAP_DEFAULT_FILLFACTOR		100
 
+/*
+ * RelationHasStdRdOptions
+ *		Returns true when the relation's rd_options buffer is safe to read
+ *		as StdRdOptions: either it was produced by the standard heap
+ *		reloption parser (the AM has no amoptions callback), or the AM's
+ *		own amoptions callback returns a struct that embeds a full
+ *		StdRdOptions as its first member and says so via
+ *		TableAmRoutine.has_std_options_prefix.  A table access method that
+ *		supplies amoptions without setting that flag owns its rd_options
+ *		layout entirely and is not required to expose StdRdOptions fields;
+ *		macros that read those fields must check this first to avoid
+ *		reading garbage, or past the end of a smaller custom struct.  For
+ *		indexes and other relkinds rd_options is in an AM-specific layout,
+ *		so this returns false for them.
+ *
+ *		Defined as a function (in reloptions.c) rather than a macro
+ *		because the test needs the full TableAmRoutine struct definition,
+ *		which would create an #include cycle if pulled into rel.h.
+ */
+extern bool RelationHasStdRdOptions(Relation relation);
+
 /*
  * RelationGetToastTupleTarget
  *		Returns the relation's toast_tuple_target.  Note multiple eval of argument!
  */
 #define RelationGetToastTupleTarget(relation, defaulttarg) \
-	((relation)->rd_options ? \
+	(RelationHasStdRdOptions(relation) ? \
 	 ((StdRdOptions *) (relation)->rd_options)->toast_tuple_target : (defaulttarg))
 
 /*
@@ -394,7 +415,7 @@ typedef struct StdRdOptions
  *		Returns the relation's toast_value_type.  Note multiple eval of argument!
  */
 #define RelationGetToastValueType(relation, defaulttarg) \
-	((relation)->rd_options ? \
+	(RelationHasStdRdOptions(relation) ? \
 	 ((StdRdOptions *) (relation)->rd_options)->toast_value_type : (defaulttarg))
 
 /*
@@ -402,7 +423,7 @@ typedef struct StdRdOptions
  *		Returns the relation's fillfactor.  Note multiple eval of argument!
  */
 #define RelationGetFillFactor(relation, defaultff) \
-	((relation)->rd_options ? \
+	(RelationHasStdRdOptions(relation) ? \
 	 ((StdRdOptions *) (relation)->rd_options)->fillfactor : (defaultff))
 
 /*
@@ -425,7 +446,7 @@ typedef struct StdRdOptions
  *		from the pov of logical decoding.  Note multiple eval of argument!
  */
 #define RelationIsUsedAsCatalogTable(relation)	\
-	((relation)->rd_options && \
+	(RelationHasStdRdOptions(relation) && \
 	 ((relation)->rd_rel->relkind == RELKIND_RELATION || \
 	  (relation)->rd_rel->relkind == RELKIND_MATVIEW) ? \
 	 ((StdRdOptions *) (relation)->rd_options)->user_catalog_table : false)
@@ -436,7 +457,7 @@ typedef struct StdRdOptions
  *		Note multiple eval of argument!
  */
 #define RelationGetParallelWorkers(relation, defaultpw) \
-	((relation)->rd_options ? \
+	(RelationHasStdRdOptions(relation) ? \
 	 ((StdRdOptions *) (relation)->rd_options)->parallel_workers : (defaultpw))
 
 /* ViewOptions->check_option values */
diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index 71a2e65ad70..e3c0ce7e051 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -10,6 +10,7 @@ SUBDIRS = \
 		  delay_execution \
 		  dummy_index_am \
 		  dummy_seclabel \
+		  dummy_table_am \
 		  index \
 		  libpq_pipeline \
 		  oauth_validator \
diff --git a/src/test/modules/dummy_table_am/Makefile b/src/test/modules/dummy_table_am/Makefile
new file mode 100644
index 00000000000..cd9f63f2897
--- /dev/null
+++ b/src/test/modules/dummy_table_am/Makefile
@@ -0,0 +1,20 @@
+# src/test/modules/dummy_table_am/Makefile
+
+MODULES = dummy_table_am
+
+EXTENSION = dummy_table_am
+DATA = dummy_table_am--1.0.sql
+PGFILEDESC = "dummy_table_am - table access method template"
+
+REGRESS = reloptions custom_layout
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/dummy_table_am
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/src/test/modules/dummy_table_am/README b/src/test/modules/dummy_table_am/README
new file mode 100644
index 00000000000..90e563f1f98
--- /dev/null
+++ b/src/test/modules/dummy_table_am/README
@@ -0,0 +1,32 @@
+Dummy Table AM
+==============
+
+Dummy table AM is a module for testing the table access method
+amoptions callback and the add_reloption_to_kind() helper.  It
+delegates all storage and scan callbacks to the heap AM and only
+swaps in its own option parser, so a relation created with USING
+dummy_table_am behaves like a heap table but accepts a different
+set of reloptions:
+
+  - every field of StdRdOptions (fillfactor, toast_tuple_target,
+    toast_value_type, parallel_workers, vacuum_index_cleanup,
+    vacuum_truncate, vacuum_max_eager_freeze_failure_rate,
+    autovacuum_enabled, user_catalog_table), inherited from the core
+    heap registration via add_reloption_to_kind()
+  - "option_int"     (integer)
+  - "option_real"    (real)
+  - "option_bool"    (boolean)
+  - "option_enum"    (enum, one|two)
+
+The rest of the autovacuum_* family (e.g. autovacuum_vacuum_threshold)
+is intentionally NOT accepted, to exercise the "AM rejects an unknown
+option" path in ALTER TABLE ... SET ACCESS METHOD revalidation.
+
+The module also provides dummy_custom_table_am, whose amoptions
+callback returns a struct that does not begin with StdRdOptions, and
+which leaves has_std_options_prefix unset.  It accepts only three
+integer options, "option_a", "option_b" and "option_c" (0..100,
+default 0), which occupy the offsets of StdRdOptions.fillfactor,
+toast_tuple_target and toast_value_type.  It exists to check that core
+code does not read standard options out of such a relation's
+rd_options.
diff --git a/src/test/modules/dummy_table_am/dummy_table_am--1.0.sql b/src/test/modules/dummy_table_am/dummy_table_am--1.0.sql
new file mode 100644
index 00000000000..fb438a8c735
--- /dev/null
+++ b/src/test/modules/dummy_table_am/dummy_table_am--1.0.sql
@@ -0,0 +1,21 @@
+/* src/test/modules/dummy_table_am/dummy_table_am--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION dummy_table_am" to load this file. \quit
+
+CREATE FUNCTION dthandler(internal)
+RETURNS table_am_handler
+AS 'MODULE_PATHNAME'
+LANGUAGE C;
+
+-- Access method
+CREATE ACCESS METHOD dummy_table_am TYPE TABLE HANDLER dthandler;
+COMMENT ON ACCESS METHOD dummy_table_am IS 'dummy table access method';
+
+CREATE FUNCTION dcthandler(internal)
+RETURNS table_am_handler
+AS 'MODULE_PATHNAME'
+LANGUAGE C;
+
+CREATE ACCESS METHOD dummy_custom_table_am TYPE TABLE HANDLER dcthandler;
+COMMENT ON ACCESS METHOD dummy_custom_table_am IS 'dummy table access method with a custom options layout';
diff --git a/src/test/modules/dummy_table_am/dummy_table_am.c b/src/test/modules/dummy_table_am/dummy_table_am.c
new file mode 100644
index 00000000000..11f24a7d5fa
--- /dev/null
+++ b/src/test/modules/dummy_table_am/dummy_table_am.c
@@ -0,0 +1,368 @@
+/*-------------------------------------------------------------------------
+ *
+ * dummy_table_am.c
+ *		Table AM template main file.
+ *
+ * This module exists primarily to demonstrate and exercise the table AM
+ * amoptions callback and the add_reloption_to_kind() helper.  Storage
+ * and scan callbacks are delegated to the heap AM, so a relation
+ * created with USING dummy_table_am behaves like a heap table; only the
+ * reloption surface differs.
+ *
+ * It provides two access methods: dummy_table_am, whose options struct
+ * embeds StdRdOptions and sets has_std_options_prefix, and
+ * dummy_custom_table_am, whose options struct has a layout of its own.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/test/modules/dummy_table_am/dummy_table_am.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "access/reloptions.h"
+#include "access/tableam.h"
+#include "catalog/pg_am_d.h"
+#include "fmgr.h"
+#include "utils/rel.h"
+
+PG_MODULE_MAGIC;
+
+/* Parse table for build_reloptions: 9 inherited standard options + 4 of our own */
+static relopt_parse_elt dt_relopt_tab[13];
+
+/* Kind of relation options for dummy table */
+static relopt_kind dt_relopt_kind;
+
+/* Parse table and kind of relation options for dummy_custom_table_am */
+static relopt_parse_elt dct_relopt_tab[3];
+static relopt_kind dct_relopt_kind;
+
+typedef enum DummyTableEnum
+{
+	DUMMY_TABLE_ENUM_ONE,
+	DUMMY_TABLE_ENUM_TWO,
+}			DummyTableEnum;
+
+/*
+ * Dummy table options.
+ *
+ * This AM sets TableAmRoutine.has_std_options_prefix (see dthandler()
+ * below), which promises core code that rd_options begins with a complete,
+ * valid StdRdOptions it may read directly -- RelationGetFillFactor(), the
+ * autovacuum option readers, and so on.  "std" is that StdRdOptions, and
+ * must be the first member.
+ *
+ * The promise only holds if every field of "std" carries a sensible value
+ * even when the user set nothing.  build_reloptions() fills exactly the
+ * fields listed in the parse table (with the option's default when unset)
+ * and leaves the rest zeroed -- and zero is the wrong "unset" value for
+ * several of them (parallel_workers and
+ * vacuum_max_eager_freeze_failure_rate both use -1; fillfactor's default
+ * is HEAP_DEFAULT_FILLFACTOR).  That is why create_reloptions_table()
+ * inherits and registers every option heap's default_reloptions()
+ * understands, not just the ones this module is interesting for.
+ *
+ * The remaining four are AM-specific options that only dummy_table_am
+ * knows about.
+ */
+typedef struct DummyTableOptions
+{
+	StdRdOptions std;			/* must be first, see above */
+	int			option_int;
+	double		option_real;
+	bool		option_bool;
+	DummyTableEnum option_enum;
+}			DummyTableOptions;
+
+/*
+ * dummy_custom_table_am options.
+ *
+ * Unlike DummyTableOptions this does not embed StdRdOptions, and
+ * dummy_custom_table_am leaves has_std_options_prefix unset, so core code
+ * must not read StdRdOptions fields out of rd_options for its relations.
+ * The struct is smaller than StdRdOptions, and each field sits at the
+ * offset of a StdRdOptions field (fillfactor, toast_tuple_target and
+ * toast_value_type respectively), so a core read that does not check
+ * RelationHasStdRdOptions() picks up one of these values instead of the
+ * default and changes observable behavior.
+ */
+typedef struct DummyCustomTableOptions
+{
+	int32		vl_len_;		/* varlena header (do not touch directly!) */
+	int			option_a;
+	int			option_b;
+	int			option_c;
+}			DummyCustomTableOptions;
+
+static relopt_enum_elt_def dummyTableEnumValues[] =
+{
+	{"one", DUMMY_TABLE_ENUM_ONE},
+	{"two", DUMMY_TABLE_ENUM_TWO},
+	{(const char *) NULL}		/* list terminator */
+};
+
+static void create_reloptions_table(void);
+static void create_custom_reloptions_table(void);
+static bytea *dtoptions(Datum reloptions, bool validate);
+static bytea *dctoptions(Datum reloptions, bool validate);
+static Oid	dummy_table_relation_toast_am(Relation rel);
+
+PG_FUNCTION_INFO_V1(dthandler);
+PG_FUNCTION_INFO_V1(dcthandler);
+
+/*
+ * Register a relopt_kind for this AM and populate the parse table.
+ */
+static void
+create_reloptions_table(void)
+{
+	int			i = 0;
+
+	dt_relopt_kind = add_reloption_kind();
+
+	/*
+	 * Accept every standard option that core's default_reloptions()
+	 * understands (registered for RELOPT_KIND_HEAP and/or RELOPT_KIND_TOAST)
+	 * under our own kind.  This is the canonical use of
+	 * add_reloption_to_kind(): an AM that wants to honour existing
+	 * core-registered options without duplicating their definitions.  All of
+	 * them, not just the interesting ones, must be both inherited and listed
+	 * in the parse table, or the corresponding DummyTableOptions.std fields
+	 * would stay zeroed rather than get their defaults -- and core code reads
+	 * those fields directly because of has_std_options_prefix (see the
+	 * comment on DummyTableOptions).
+	 */
+	add_reloption_to_kind("fillfactor", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "fillfactor";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_INT;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.fillfactor);
+	i++;
+
+	add_reloption_to_kind("toast_tuple_target", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "toast_tuple_target";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_INT;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.toast_tuple_target);
+	i++;
+
+	add_reloption_to_kind("toast_value_type", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "toast_value_type";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_ENUM;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.toast_value_type);
+	i++;
+
+	add_reloption_to_kind("parallel_workers", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "parallel_workers";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_INT;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.parallel_workers);
+	i++;
+
+	add_reloption_to_kind("vacuum_index_cleanup", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "vacuum_index_cleanup";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_ENUM;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.vacuum_index_cleanup);
+	i++;
+
+	add_reloption_to_kind("vacuum_truncate", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "vacuum_truncate";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_TERNARY;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.vacuum_truncate);
+	i++;
+
+	add_reloption_to_kind("vacuum_max_eager_freeze_failure_rate", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "vacuum_max_eager_freeze_failure_rate";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_REAL;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.vacuum_max_eager_freeze_failure_rate);
+	i++;
+
+	add_reloption_to_kind("autovacuum_enabled", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "autovacuum_enabled";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_TERNARY;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.autovacuum.enabled);
+	i++;
+
+	add_reloption_to_kind("user_catalog_table", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "user_catalog_table";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_BOOL;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.user_catalog_table);
+	i++;
+
+	add_int_reloption(dt_relopt_kind, "option_int",
+					  "Integer option for dummy_table_am",
+					  10, -10, 100, AccessExclusiveLock);
+	dt_relopt_tab[i].optname = "option_int";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_INT;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, option_int);
+	i++;
+
+	add_real_reloption(dt_relopt_kind, "option_real",
+					   "Real option for dummy_table_am",
+					   3.1415, -10, 100, AccessExclusiveLock);
+	dt_relopt_tab[i].optname = "option_real";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_REAL;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, option_real);
+	i++;
+
+	add_bool_reloption(dt_relopt_kind, "option_bool",
+					   "Boolean option for dummy_table_am",
+					   true, AccessExclusiveLock);
+	dt_relopt_tab[i].optname = "option_bool";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_BOOL;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, option_bool);
+	i++;
+
+	add_enum_reloption(dt_relopt_kind, "option_enum",
+					   "Enum option for dummy_table_am",
+					   dummyTableEnumValues,
+					   DUMMY_TABLE_ENUM_ONE,
+					   "Valid values are \"one\" and \"two\".",
+					   AccessExclusiveLock);
+	dt_relopt_tab[i].optname = "option_enum";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_ENUM;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, option_enum);
+	i++;
+}
+
+/*
+ * Register a relopt_kind for dummy_custom_table_am and populate its parse
+ * table.
+ */
+static void
+create_custom_reloptions_table(void)
+{
+	static const char *const names[] = {"option_a", "option_b", "option_c"};
+	static const int offsets[] = {
+		offsetof(DummyCustomTableOptions, option_a),
+		offsetof(DummyCustomTableOptions, option_b),
+		offsetof(DummyCustomTableOptions, option_c),
+	};
+
+	StaticAssertDecl(lengthof(names) == lengthof(dct_relopt_tab),
+					 "names and dct_relopt_tab must have the same length");
+	StaticAssertDecl(lengthof(offsets) == lengthof(dct_relopt_tab),
+					 "offsets and dct_relopt_tab must have the same length");
+
+	dct_relopt_kind = add_reloption_kind();
+
+	for (int i = 0; i < lengthof(dct_relopt_tab); i++)
+	{
+		add_int_reloption(dct_relopt_kind, names[i],
+						  "Integer option for dummy_custom_table_am",
+						  0, 0, 100, AccessExclusiveLock);
+		dct_relopt_tab[i].optname = names[i];
+		dct_relopt_tab[i].opttype = RELOPT_TYPE_INT;
+		dct_relopt_tab[i].offset = offsets[i];
+	}
+}
+
+/*
+ * Parse reloptions for dummy_table_am.
+ *
+ * Returning DummyTableOptions tells the caller (relcache.c) to store
+ * exactly that layout in Relation->rd_options.
+ */
+static bytea *
+dtoptions(Datum reloptions, bool validate)
+{
+	return (bytea *) build_reloptions(reloptions, validate,
+									  dt_relopt_kind,
+									  sizeof(DummyTableOptions),
+									  dt_relopt_tab, lengthof(dt_relopt_tab));
+}
+
+/*
+ * Parse reloptions for dummy_custom_table_am.
+ */
+static bytea *
+dctoptions(Datum reloptions, bool validate)
+{
+	return (bytea *) build_reloptions(reloptions, validate,
+									  dct_relopt_kind,
+									  sizeof(DummyCustomTableOptions),
+									  dct_relopt_tab, lengthof(dct_relopt_tab));
+}
+
+/*
+ * heapam_relation_toast_am() (heap's own relation_toast_am callback, which
+ * we would otherwise inherit unchanged along with the rest of heap's
+ * routine) returns rel->rd_rel->relam -- correct for a real heap table, but
+ * for dummy_table_am that's dummy_table_am's own oid, not heap's.  That
+ * would make this AM's TOAST tables dummy_table_am relations too, and
+ * building their chunk_id/chunk_seq index fails as soon as it's scanned,
+ * since that scan goes through heap_getnext() directly.  Override it to
+ * return the literal heap AM oid: this AM's TOAST tables are always plain
+ * heap, regardless of what created the owning table.
+ */
+static Oid
+dummy_table_relation_toast_am(Relation rel)
+{
+	return HEAP_TABLE_AM_OID;
+}
+
+/*
+ * Handler for table AM.
+ *
+ * All storage-side callbacks are inherited from heap; we swap in our own
+ * amoptions so that the AM owns its reloption set, and our own
+ * relation_toast_am (see dummy_table_relation_toast_am() above).  This
+ * keeps the example focused on the new API without duplicating the heap
+ * AM.
+ *
+ * has_std_options_prefix is set because DummyTableOptions embeds a full
+ * StdRdOptions as its first member with every field populated (see the
+ * comment on DummyTableOptions): that makes it safe for core code to keep
+ * reading fillfactor and friends directly out of rd_options, exactly as
+ * it would for a plain heap table.
+ */
+Datum
+dthandler(PG_FUNCTION_ARGS)
+{
+	static TableAmRoutine routine;
+	static bool initialized = false;
+
+	if (!initialized)
+	{
+		memcpy(&routine, GetHeapamTableAmRoutine(), sizeof(routine));
+		routine.amoptions = dtoptions;
+		routine.has_std_options_prefix = true;
+		routine.relation_toast_am = dummy_table_relation_toast_am;
+		initialized = true;
+	}
+
+	PG_RETURN_POINTER(&routine);
+}
+
+/*
+ * Handler for dummy_custom_table_am.
+ *
+ * Same as dthandler(), except that has_std_options_prefix is false:
+ * DummyCustomTableOptions is not laid out as StdRdOptions, so core code
+ * has to treat a relation of this AM as having no standard options set.
+ */
+Datum
+dcthandler(PG_FUNCTION_ARGS)
+{
+	static TableAmRoutine routine;
+	static bool initialized = false;
+
+	if (!initialized)
+	{
+		memcpy(&routine, GetHeapamTableAmRoutine(), sizeof(routine));
+		routine.amoptions = dctoptions;
+		routine.has_std_options_prefix = false;
+		routine.relation_toast_am = dummy_table_relation_toast_am;
+		initialized = true;
+	}
+
+	PG_RETURN_POINTER(&routine);
+}
+
+void
+_PG_init(void)
+{
+	create_reloptions_table();
+	create_custom_reloptions_table();
+}
diff --git a/src/test/modules/dummy_table_am/dummy_table_am.control b/src/test/modules/dummy_table_am/dummy_table_am.control
new file mode 100644
index 00000000000..08f2f868d49
--- /dev/null
+++ b/src/test/modules/dummy_table_am/dummy_table_am.control
@@ -0,0 +1,5 @@
+# dummy_table_am extension
+comment = 'dummy_table_am - table access method template'
+default_version = '1.0'
+module_pathname = '$libdir/dummy_table_am'
+relocatable = true
diff --git a/src/test/modules/dummy_table_am/expected/custom_layout.out b/src/test/modules/dummy_table_am/expected/custom_layout.out
new file mode 100644
index 00000000000..300a7291c71
--- /dev/null
+++ b/src/test/modules/dummy_table_am/expected/custom_layout.out
@@ -0,0 +1,68 @@
+-- Tests for a table AM whose amoptions callback returns a layout of its
+-- own, without a StdRdOptions prefix (has_std_options_prefix = false).
+CREATE EXTENSION dummy_table_am;
+-- Standard heap options are not accepted by this AM.
+CREATE TABLE dct_bad (a int) USING dummy_custom_table_am WITH (fillfactor = 50);
+ERROR:  unrecognized parameter "fillfactor"
+-- TOAST table creation must use the default toast_value_type rather than
+-- reading StdRdOptions.toast_value_type out of rd_options: option_c sits at
+-- that offset, and 0 is not a valid toast_value_type while 2 would select
+-- oid8.
+CREATE TABLE dct_default (a int, b text) USING dummy_custom_table_am;
+CREATE TABLE dct_c0 (a int, b text) USING dummy_custom_table_am
+    WITH (option_c = 0);
+CREATE TABLE dct_c2 (a int, b text) USING dummy_custom_table_am
+    WITH (option_a = 10, option_b = 20, option_c = 2);
+SELECT c.relname, c.reloptions, a.atttypid::regtype AS chunk_id_type
+    FROM pg_class c
+    JOIN pg_attribute a ON a.attrelid = c.reltoastrelid AND a.attname = 'chunk_id'
+    WHERE c.relname IN ('dct_default', 'dct_c0', 'dct_c2')
+    ORDER BY c.relname;
+   relname   |              reloptions              | chunk_id_type 
+-------------+--------------------------------------+---------------
+ dct_c0      | {option_c=0}                         | oid
+ dct_c2      | {option_a=10,option_b=20,option_c=2} | oid
+ dct_default |                                      | oid
+(3 rows)
+
+-- VACUUM hands the main table's storage parameters down to its TOAST
+-- table; that must not copy a StdRdOptions out of this AM's smaller
+-- rd_options.
+INSERT INTO dct_c2
+    SELECT 1, string_agg(md5(i::text), '') FROM generate_series(1, 500) i;
+SELECT pg_relation_size(reltoastrelid) > 0 AS has_toast_data
+    FROM pg_class WHERE oid = 'dct_c2'::regclass;
+ has_toast_data 
+----------------
+ t
+(1 row)
+
+VACUUM dct_c2;
+SELECT a, length(b) FROM dct_c2;
+ a | length 
+---+--------
+ 1 |  16000
+(1 row)
+
+-- Switching to heap revalidates the options against heap's parser.
+ALTER TABLE dct_c2 SET ACCESS METHOD heap;
+ERROR:  unrecognized parameter "option_a"
+ALTER TABLE dct_c2 SET ACCESS METHOD heap, SET (fillfactor = 50),
+    RESET (option_a, option_b, option_c);
+SELECT (SELECT amname FROM pg_am WHERE oid = relam) AS amname, reloptions
+    FROM pg_class WHERE oid = 'dct_c2'::regclass;
+ amname |   reloptions    
+--------+-----------------
+ heap   | {fillfactor=50}
+(1 row)
+
+SELECT a, length(b) FROM dct_c2;
+ a | length 
+---+--------
+ 1 |  16000
+(1 row)
+
+DROP TABLE dct_default;
+DROP TABLE dct_c0;
+DROP TABLE dct_c2;
+DROP EXTENSION dummy_table_am;
diff --git a/src/test/modules/dummy_table_am/expected/reloptions.out b/src/test/modules/dummy_table_am/expected/reloptions.out
new file mode 100644
index 00000000000..32230566d2d
--- /dev/null
+++ b/src/test/modules/dummy_table_am/expected/reloptions.out
@@ -0,0 +1,299 @@
+-- Tests for the table AM amoptions callback and add_reloption_to_kind()
+CREATE EXTENSION dummy_table_am;
+-- Sanity: CREATE TABLE with AM-specific options succeeds and round-trips
+CREATE TABLE dummy_t (a int) USING dummy_table_am
+    WITH (option_int = 17, option_real = 2.5, option_bool = false,
+          option_enum = 'two', fillfactor = 60);
+SELECT reloptions FROM pg_class
+    WHERE oid = 'dummy_t'::regclass ORDER BY reloptions;
+                                   reloptions                                    
+---------------------------------------------------------------------------------
+ {option_int=17,option_real=2.5,option_bool=false,option_enum=two,fillfactor=60}
+(1 row)
+
+-- AM-specific option ranges are enforced (option_int allows -10..100)
+CREATE TABLE dummy_oor (a int) USING dummy_table_am WITH (option_int = 9999);
+ERROR:  value 9999 out of bounds for option "option_int"
+DETAIL:  Valid values are between "-10" and "100".
+-- Unknown options are rejected at CREATE TABLE time
+CREATE TABLE dummy_bad (a int) USING dummy_table_am WITH (autovacuum_vacuum_threshold = 4);
+ERROR:  unrecognized parameter "autovacuum_vacuum_threshold"
+-- Default values land in pg_class only when the user did not set them
+CREATE TABLE dummy_defaults (a int) USING dummy_table_am;
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_defaults'::regclass;
+ reloptions 
+------------
+ 
+(1 row)
+
+DROP TABLE dummy_defaults;
+-- ALTER TABLE ... SET (...) with AM-specific option
+ALTER TABLE dummy_t SET (option_int = 42);
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_t'::regclass;
+                                   reloptions                                    
+---------------------------------------------------------------------------------
+ {option_real=2.5,option_bool=false,option_enum=two,fillfactor=60,option_int=42}
+(1 row)
+
+-- ALTER TABLE ... SET (...) with an unknown option errors
+ALTER TABLE dummy_t SET (autovacuum_vacuum_threshold = 4);
+ERROR:  unrecognized parameter "autovacuum_vacuum_threshold"
+-- ALTER TABLE ... RESET (option) round-trips
+ALTER TABLE dummy_t RESET (option_int);
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_t'::regclass;
+                            reloptions                             
+-------------------------------------------------------------------
+ {option_real=2.5,option_bool=false,option_enum=two,fillfactor=60}
+(1 row)
+
+-- SET ACCESS METHOD revalidation:
+--   moving a heap table that has standard heap options not accepted by the
+--   new AM (autovacuum_vacuum_threshold; dummy_table_am inherits
+--   autovacuum_enabled but not the rest of the autovacuum_* family) into
+--   dummy_table_am must fail with a clear message and must NOT silently
+--   drop the option.
+CREATE TABLE heap_t (a int) WITH (fillfactor = 70, autovacuum_vacuum_threshold = 4);
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+                  reloptions                   
+-----------------------------------------------
+ {fillfactor=70,autovacuum_vacuum_threshold=4}
+(1 row)
+
+ALTER TABLE heap_t SET ACCESS METHOD dummy_table_am;
+ERROR:  unrecognized parameter "autovacuum_vacuum_threshold"
+-- Confirm nothing changed
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+ amname 
+--------
+ heap
+(1 row)
+
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+                  reloptions                   
+-----------------------------------------------
+ {fillfactor=70,autovacuum_vacuum_threshold=4}
+(1 row)
+
+-- After RESETing the offending option in the same statement the swap
+-- succeeds; fillfactor survives because dummy_table_am inherits it via
+-- add_reloption_to_kind().
+ALTER TABLE heap_t SET ACCESS METHOD dummy_table_am, RESET (autovacuum_vacuum_threshold);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+     amname     
+----------------
+ dummy_table_am
+(1 row)
+
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+   reloptions    
+-----------------
+ {fillfactor=70}
+(1 row)
+
+-- Going back to heap still works: heap accepts fillfactor.
+ALTER TABLE heap_t SET ACCESS METHOD heap;
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+ amname 
+--------
+ heap
+(1 row)
+
+-- SET ACCESS METHOD + SET (...) of an option that only the new AM accepts.
+CREATE TABLE heap_to_dt (a int);
+ALTER TABLE heap_to_dt SET ACCESS METHOD dummy_table_am, SET (option_int = 25);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_to_dt'::regclass;
+     amname     
+----------------
+ dummy_table_am
+(1 row)
+
+SELECT reloptions FROM pg_class WHERE oid = 'heap_to_dt'::regclass;
+   reloptions    
+-----------------
+ {option_int=25}
+(1 row)
+
+-- The reverse direction must be caught too: heap has no amoptions of its
+-- own (table_reloptions() just falls back to heap_reloptions()), but that
+-- is not a reason to skip validation.  option_int is dummy_table_am-only,
+-- so switching back to heap while it is still set must fail the same way,
+-- not silently drop it at the next relcache load.
+ALTER TABLE heap_to_dt SET ACCESS METHOD heap;
+ERROR:  unrecognized parameter "option_int"
+-- Confirm nothing changed
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_to_dt'::regclass;
+     amname     
+----------------
+ dummy_table_am
+(1 row)
+
+SELECT reloptions FROM pg_class WHERE oid = 'heap_to_dt'::regclass;
+   reloptions    
+-----------------
+ {option_int=25}
+(1 row)
+
+-- RESETting the offending option in the same statement lets it through
+ALTER TABLE heap_to_dt SET ACCESS METHOD heap, RESET (option_int);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_to_dt'::regclass;
+ amname 
+--------
+ heap
+(1 row)
+
+SELECT reloptions FROM pg_class WHERE oid = 'heap_to_dt'::regclass;
+ reloptions 
+------------
+ 
+(1 row)
+
+-- Only the final option list is checked against the new AM, so the order
+-- of SET and RESET subcommands does not matter: the SET below must not be
+-- rejected for option_int, which the later RESET removes.  An option that
+-- remains in the final list and that the new AM does not know is still
+-- rejected.
+ALTER TABLE heap_to_dt SET ACCESS METHOD dummy_table_am, SET (option_int = 25);
+ALTER TABLE heap_to_dt SET ACCESS METHOD heap, SET (option_real = 1),
+    RESET (option_int);
+ERROR:  unrecognized parameter "option_real"
+ALTER TABLE heap_to_dt SET ACCESS METHOD heap, SET (fillfactor = 50),
+    RESET (option_int);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_to_dt'::regclass;
+ amname 
+--------
+ heap
+(1 row)
+
+SELECT reloptions FROM pg_class WHERE oid = 'heap_to_dt'::regclass;
+   reloptions    
+-----------------
+ {fillfactor=50}
+(1 row)
+
+-- The deferred check still enforces the new AM's option ranges.
+ALTER TABLE heap_to_dt SET ACCESS METHOD dummy_table_am, SET (option_int = 9999);
+ERROR:  value 9999 out of bounds for option "option_int"
+DETAIL:  Valid values are between "-10" and "100".
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_to_dt'::regclass;
+ amname 
+--------
+ heap
+(1 row)
+
+SELECT reloptions FROM pg_class WHERE oid = 'heap_to_dt'::regclass;
+   reloptions    
+-----------------
+ {fillfactor=50}
+(1 row)
+
+-- fillfactor genuinely reaches heap's own page-packing logic now, not just
+-- pg_class.reloptions: dummy_table_am embeds a full StdRdOptions as the
+-- first member of its own options struct and sets
+-- TableAmRoutine.has_std_options_prefix, so RelationGetFillFactor() can read
+-- it directly instead of always seeing the hardcoded default.
+CREATE TABLE dummy_ff10 (a int) USING dummy_table_am WITH (fillfactor = 10);
+CREATE TABLE dummy_ff100 (a int) USING dummy_table_am WITH (fillfactor = 100);
+INSERT INTO dummy_ff10 SELECT generate_series(1, 5000);
+INSERT INTO dummy_ff100 SELECT generate_series(1, 5000);
+VACUUM dummy_ff10;
+VACUUM dummy_ff100;
+SELECT (SELECT relpages FROM pg_class WHERE oid = 'dummy_ff10'::regclass) >
+       (SELECT relpages FROM pg_class WHERE oid = 'dummy_ff100'::regclass)
+       AS low_fillfactor_uses_more_pages;
+ low_fillfactor_uses_more_pages 
+--------------------------------
+ t
+(1 row)
+
+DROP TABLE dummy_ff10;
+DROP TABLE dummy_ff100;
+-- A table with a toastable column works: dummy_table_am overrides
+-- relation_toast_am rather than inheriting heap's, which would return
+-- this AM's own oid instead of heap's for its TOAST table, making that
+-- TOAST table itself a dummy_table_am relation and failing as soon as
+-- its chunk_id/chunk_seq index was built (that scan goes through
+-- heap_getnext() directly, which requires a real heap relation).
+CREATE TABLE dummy_txt (a int, b text) USING dummy_table_am;
+INSERT INTO dummy_txt VALUES (1, repeat('x', 10000));
+SELECT a, length(b) FROM dummy_txt;
+ a | length 
+---+--------
+ 1 |  10000
+(1 row)
+
+DROP TABLE dummy_txt;
+-- The same with a reloption set: rd_options is non-NULL going into
+-- create_toast_table(), which reads toast_value_type straight out of it
+-- via RelationGetToastValueType().  toast_value_type must be registered
+-- like every other StdRdOptions field, or that read finds it zeroed
+-- rather than defaulted to STDRD_OPTION_TOAST_VALUE_TYPE_OID.
+CREATE TABLE dummy_txt_opt (a int, b text)
+    USING dummy_table_am WITH (option_int = 7);
+INSERT INTO dummy_txt_opt VALUES (1, repeat('x', 10000));
+SELECT a, length(b) FROM dummy_txt_opt;
+ a | length 
+---+--------
+ 1 |  10000
+(1 row)
+
+DROP TABLE dummy_txt_opt;
+-- Partitioned-table inheritance: AM declared on the parent partition flows
+-- to partitions that don't override it.  Partitioned tables themselves
+-- cannot carry reloptions; the test verifies the AM lookup that
+-- DefineRelation does for partitions.
+CREATE TABLE parted (a int) PARTITION BY RANGE (a) USING dummy_table_am;
+CREATE TABLE parted_p1 PARTITION OF parted FOR VALUES FROM (0) TO (100)
+    WITH (option_int = 11);
+SELECT c.relname,
+       (SELECT amname FROM pg_am WHERE oid = c.relam) AS amname,
+       c.reloptions
+    FROM pg_class c
+    WHERE c.oid IN ('parted'::regclass, 'parted_p1'::regclass)
+    ORDER BY c.relname;
+  relname  |     amname     |   reloptions    
+-----------+----------------+-----------------
+ parted    | dummy_table_am | 
+ parted_p1 | dummy_table_am | {option_int=11}
+(2 rows)
+
+-- A partition that explicitly chooses heap must reject options that are
+-- only known to the parent's AM.
+CREATE TABLE parted_p2 PARTITION OF parted FOR VALUES FROM (100) TO (200)
+    USING heap WITH (option_int = 9);
+ERROR:  unrecognized parameter "option_int"
+-- A parent created without USING has no AM of its own (relam = 0); a
+-- partition of it takes default_table_access_method, so its reloptions
+-- must be validated by that AM, not silently fall through to heap's
+-- parser (which would reject the AM's own options and accept heap-only
+-- ones the AM would then drop).
+SET default_table_access_method = dummy_table_am;
+CREATE TABLE parted_noam (a int) PARTITION BY RANGE (a);
+CREATE TABLE parted_noam_p1 PARTITION OF parted_noam
+    FOR VALUES FROM (0) TO (100) WITH (option_int = 12);
+SELECT c.relname,
+       (SELECT amname FROM pg_am WHERE oid = c.relam) AS amname,
+       c.reloptions
+    FROM pg_class c
+    WHERE c.oid IN ('parted_noam'::regclass, 'parted_noam_p1'::regclass)
+    ORDER BY c.relname;
+    relname     |     amname     |   reloptions    
+----------------+----------------+-----------------
+ parted_noam    |                | 
+ parted_noam_p1 | dummy_table_am | {option_int=12}
+(2 rows)
+
+RESET default_table_access_method;
+DROP TABLE parted_noam;
+DROP TABLE parted;
+DROP TABLE heap_to_dt;
+DROP TABLE heap_t;
+DROP TABLE dummy_t;
+DROP EXTENSION dummy_table_am;
diff --git a/src/test/modules/dummy_table_am/meson.build b/src/test/modules/dummy_table_am/meson.build
new file mode 100644
index 00000000000..35ffde4d151
--- /dev/null
+++ b/src/test/modules/dummy_table_am/meson.build
@@ -0,0 +1,34 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+dummy_table_am_sources = files(
+  'dummy_table_am.c',
+)
+
+if host_system == 'windows'
+  dummy_table_am_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'dummy_table_am',
+    '--FILEDESC', 'dummy_table_am - table access method template',])
+endif
+
+dummy_table_am = shared_module('dummy_table_am',
+  dummy_table_am_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += dummy_table_am
+
+test_install_data += files(
+  'dummy_table_am.control',
+  'dummy_table_am--1.0.sql',
+)
+
+tests += {
+  'name': 'dummy_table_am',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      'reloptions',
+      'custom_layout',
+    ],
+  },
+}
diff --git a/src/test/modules/dummy_table_am/sql/custom_layout.sql b/src/test/modules/dummy_table_am/sql/custom_layout.sql
new file mode 100644
index 00000000000..090675c1f31
--- /dev/null
+++ b/src/test/modules/dummy_table_am/sql/custom_layout.sql
@@ -0,0 +1,45 @@
+-- Tests for a table AM whose amoptions callback returns a layout of its
+-- own, without a StdRdOptions prefix (has_std_options_prefix = false).
+CREATE EXTENSION dummy_table_am;
+
+-- Standard heap options are not accepted by this AM.
+CREATE TABLE dct_bad (a int) USING dummy_custom_table_am WITH (fillfactor = 50);
+
+-- TOAST table creation must use the default toast_value_type rather than
+-- reading StdRdOptions.toast_value_type out of rd_options: option_c sits at
+-- that offset, and 0 is not a valid toast_value_type while 2 would select
+-- oid8.
+CREATE TABLE dct_default (a int, b text) USING dummy_custom_table_am;
+CREATE TABLE dct_c0 (a int, b text) USING dummy_custom_table_am
+    WITH (option_c = 0);
+CREATE TABLE dct_c2 (a int, b text) USING dummy_custom_table_am
+    WITH (option_a = 10, option_b = 20, option_c = 2);
+SELECT c.relname, c.reloptions, a.atttypid::regtype AS chunk_id_type
+    FROM pg_class c
+    JOIN pg_attribute a ON a.attrelid = c.reltoastrelid AND a.attname = 'chunk_id'
+    WHERE c.relname IN ('dct_default', 'dct_c0', 'dct_c2')
+    ORDER BY c.relname;
+
+-- VACUUM hands the main table's storage parameters down to its TOAST
+-- table; that must not copy a StdRdOptions out of this AM's smaller
+-- rd_options.
+INSERT INTO dct_c2
+    SELECT 1, string_agg(md5(i::text), '') FROM generate_series(1, 500) i;
+SELECT pg_relation_size(reltoastrelid) > 0 AS has_toast_data
+    FROM pg_class WHERE oid = 'dct_c2'::regclass;
+VACUUM dct_c2;
+SELECT a, length(b) FROM dct_c2;
+
+-- Switching to heap revalidates the options against heap's parser.
+ALTER TABLE dct_c2 SET ACCESS METHOD heap;
+ALTER TABLE dct_c2 SET ACCESS METHOD heap, SET (fillfactor = 50),
+    RESET (option_a, option_b, option_c);
+SELECT (SELECT amname FROM pg_am WHERE oid = relam) AS amname, reloptions
+    FROM pg_class WHERE oid = 'dct_c2'::regclass;
+SELECT a, length(b) FROM dct_c2;
+
+DROP TABLE dct_default;
+DROP TABLE dct_c0;
+DROP TABLE dct_c2;
+
+DROP EXTENSION dummy_table_am;
diff --git a/src/test/modules/dummy_table_am/sql/reloptions.sql b/src/test/modules/dummy_table_am/sql/reloptions.sql
new file mode 100644
index 00000000000..0a90a3fec35
--- /dev/null
+++ b/src/test/modules/dummy_table_am/sql/reloptions.sql
@@ -0,0 +1,184 @@
+-- Tests for the table AM amoptions callback and add_reloption_to_kind()
+CREATE EXTENSION dummy_table_am;
+
+-- Sanity: CREATE TABLE with AM-specific options succeeds and round-trips
+CREATE TABLE dummy_t (a int) USING dummy_table_am
+    WITH (option_int = 17, option_real = 2.5, option_bool = false,
+          option_enum = 'two', fillfactor = 60);
+SELECT reloptions FROM pg_class
+    WHERE oid = 'dummy_t'::regclass ORDER BY reloptions;
+
+-- AM-specific option ranges are enforced (option_int allows -10..100)
+CREATE TABLE dummy_oor (a int) USING dummy_table_am WITH (option_int = 9999);
+
+-- Unknown options are rejected at CREATE TABLE time
+CREATE TABLE dummy_bad (a int) USING dummy_table_am WITH (autovacuum_vacuum_threshold = 4);
+
+-- Default values land in pg_class only when the user did not set them
+CREATE TABLE dummy_defaults (a int) USING dummy_table_am;
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_defaults'::regclass;
+DROP TABLE dummy_defaults;
+
+-- ALTER TABLE ... SET (...) with AM-specific option
+ALTER TABLE dummy_t SET (option_int = 42);
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_t'::regclass;
+
+-- ALTER TABLE ... SET (...) with an unknown option errors
+ALTER TABLE dummy_t SET (autovacuum_vacuum_threshold = 4);
+
+-- ALTER TABLE ... RESET (option) round-trips
+ALTER TABLE dummy_t RESET (option_int);
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_t'::regclass;
+
+-- SET ACCESS METHOD revalidation:
+--   moving a heap table that has standard heap options not accepted by the
+--   new AM (autovacuum_vacuum_threshold; dummy_table_am inherits
+--   autovacuum_enabled but not the rest of the autovacuum_* family) into
+--   dummy_table_am must fail with a clear message and must NOT silently
+--   drop the option.
+CREATE TABLE heap_t (a int) WITH (fillfactor = 70, autovacuum_vacuum_threshold = 4);
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+ALTER TABLE heap_t SET ACCESS METHOD dummy_table_am;
+-- Confirm nothing changed
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+
+-- After RESETing the offending option in the same statement the swap
+-- succeeds; fillfactor survives because dummy_table_am inherits it via
+-- add_reloption_to_kind().
+ALTER TABLE heap_t SET ACCESS METHOD dummy_table_am, RESET (autovacuum_vacuum_threshold);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+
+-- Going back to heap still works: heap accepts fillfactor.
+ALTER TABLE heap_t SET ACCESS METHOD heap;
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+
+-- SET ACCESS METHOD + SET (...) of an option that only the new AM accepts.
+CREATE TABLE heap_to_dt (a int);
+ALTER TABLE heap_to_dt SET ACCESS METHOD dummy_table_am, SET (option_int = 25);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_to_dt'::regclass;
+SELECT reloptions FROM pg_class WHERE oid = 'heap_to_dt'::regclass;
+
+-- The reverse direction must be caught too: heap has no amoptions of its
+-- own (table_reloptions() just falls back to heap_reloptions()), but that
+-- is not a reason to skip validation.  option_int is dummy_table_am-only,
+-- so switching back to heap while it is still set must fail the same way,
+-- not silently drop it at the next relcache load.
+ALTER TABLE heap_to_dt SET ACCESS METHOD heap;
+-- Confirm nothing changed
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_to_dt'::regclass;
+SELECT reloptions FROM pg_class WHERE oid = 'heap_to_dt'::regclass;
+-- RESETting the offending option in the same statement lets it through
+ALTER TABLE heap_to_dt SET ACCESS METHOD heap, RESET (option_int);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_to_dt'::regclass;
+SELECT reloptions FROM pg_class WHERE oid = 'heap_to_dt'::regclass;
+
+-- Only the final option list is checked against the new AM, so the order
+-- of SET and RESET subcommands does not matter: the SET below must not be
+-- rejected for option_int, which the later RESET removes.  An option that
+-- remains in the final list and that the new AM does not know is still
+-- rejected.
+ALTER TABLE heap_to_dt SET ACCESS METHOD dummy_table_am, SET (option_int = 25);
+ALTER TABLE heap_to_dt SET ACCESS METHOD heap, SET (option_real = 1),
+    RESET (option_int);
+ALTER TABLE heap_to_dt SET ACCESS METHOD heap, SET (fillfactor = 50),
+    RESET (option_int);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_to_dt'::regclass;
+SELECT reloptions FROM pg_class WHERE oid = 'heap_to_dt'::regclass;
+
+-- The deferred check still enforces the new AM's option ranges.
+ALTER TABLE heap_to_dt SET ACCESS METHOD dummy_table_am, SET (option_int = 9999);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_to_dt'::regclass;
+SELECT reloptions FROM pg_class WHERE oid = 'heap_to_dt'::regclass;
+
+-- fillfactor genuinely reaches heap's own page-packing logic now, not just
+-- pg_class.reloptions: dummy_table_am embeds a full StdRdOptions as the
+-- first member of its own options struct and sets
+-- TableAmRoutine.has_std_options_prefix, so RelationGetFillFactor() can read
+-- it directly instead of always seeing the hardcoded default.
+CREATE TABLE dummy_ff10 (a int) USING dummy_table_am WITH (fillfactor = 10);
+CREATE TABLE dummy_ff100 (a int) USING dummy_table_am WITH (fillfactor = 100);
+INSERT INTO dummy_ff10 SELECT generate_series(1, 5000);
+INSERT INTO dummy_ff100 SELECT generate_series(1, 5000);
+VACUUM dummy_ff10;
+VACUUM dummy_ff100;
+SELECT (SELECT relpages FROM pg_class WHERE oid = 'dummy_ff10'::regclass) >
+       (SELECT relpages FROM pg_class WHERE oid = 'dummy_ff100'::regclass)
+       AS low_fillfactor_uses_more_pages;
+DROP TABLE dummy_ff10;
+DROP TABLE dummy_ff100;
+
+-- A table with a toastable column works: dummy_table_am overrides
+-- relation_toast_am rather than inheriting heap's, which would return
+-- this AM's own oid instead of heap's for its TOAST table, making that
+-- TOAST table itself a dummy_table_am relation and failing as soon as
+-- its chunk_id/chunk_seq index was built (that scan goes through
+-- heap_getnext() directly, which requires a real heap relation).
+CREATE TABLE dummy_txt (a int, b text) USING dummy_table_am;
+INSERT INTO dummy_txt VALUES (1, repeat('x', 10000));
+SELECT a, length(b) FROM dummy_txt;
+DROP TABLE dummy_txt;
+
+-- The same with a reloption set: rd_options is non-NULL going into
+-- create_toast_table(), which reads toast_value_type straight out of it
+-- via RelationGetToastValueType().  toast_value_type must be registered
+-- like every other StdRdOptions field, or that read finds it zeroed
+-- rather than defaulted to STDRD_OPTION_TOAST_VALUE_TYPE_OID.
+CREATE TABLE dummy_txt_opt (a int, b text)
+    USING dummy_table_am WITH (option_int = 7);
+INSERT INTO dummy_txt_opt VALUES (1, repeat('x', 10000));
+SELECT a, length(b) FROM dummy_txt_opt;
+DROP TABLE dummy_txt_opt;
+
+-- Partitioned-table inheritance: AM declared on the parent partition flows
+-- to partitions that don't override it.  Partitioned tables themselves
+-- cannot carry reloptions; the test verifies the AM lookup that
+-- DefineRelation does for partitions.
+CREATE TABLE parted (a int) PARTITION BY RANGE (a) USING dummy_table_am;
+CREATE TABLE parted_p1 PARTITION OF parted FOR VALUES FROM (0) TO (100)
+    WITH (option_int = 11);
+SELECT c.relname,
+       (SELECT amname FROM pg_am WHERE oid = c.relam) AS amname,
+       c.reloptions
+    FROM pg_class c
+    WHERE c.oid IN ('parted'::regclass, 'parted_p1'::regclass)
+    ORDER BY c.relname;
+
+-- A partition that explicitly chooses heap must reject options that are
+-- only known to the parent's AM.
+CREATE TABLE parted_p2 PARTITION OF parted FOR VALUES FROM (100) TO (200)
+    USING heap WITH (option_int = 9);
+
+-- A parent created without USING has no AM of its own (relam = 0); a
+-- partition of it takes default_table_access_method, so its reloptions
+-- must be validated by that AM, not silently fall through to heap's
+-- parser (which would reject the AM's own options and accept heap-only
+-- ones the AM would then drop).
+SET default_table_access_method = dummy_table_am;
+CREATE TABLE parted_noam (a int) PARTITION BY RANGE (a);
+CREATE TABLE parted_noam_p1 PARTITION OF parted_noam
+    FOR VALUES FROM (0) TO (100) WITH (option_int = 12);
+SELECT c.relname,
+       (SELECT amname FROM pg_am WHERE oid = c.relam) AS amname,
+       c.reloptions
+    FROM pg_class c
+    WHERE c.oid IN ('parted_noam'::regclass, 'parted_noam_p1'::regclass)
+    ORDER BY c.relname;
+RESET default_table_access_method;
+DROP TABLE parted_noam;
+
+DROP TABLE parted;
+DROP TABLE heap_to_dt;
+DROP TABLE heap_t;
+DROP TABLE dummy_t;
+
+DROP EXTENSION dummy_table_am;
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index 77e1a2810e5..d1fcbae0cdb 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -5,6 +5,7 @@ subdir('commit_ts')
 subdir('delay_execution')
 subdir('dummy_index_am')
 subdir('dummy_seclabel')
+subdir('dummy_table_am')
 subdir('gin')
 subdir('index')
 subdir('injection_points')
-- 
2.43.0

