From eb7fa4ae07f7bcb67b22266580392e1585a1d013 Mon Sep 17 00:00:00 2001
From: Andrew Dunstan <andrew@dunslane.net>
Date: Tue, 12 May 2026 09:03:33 -0400
Subject: [PATCH v5] Add amoptions callback to table access methods

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

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() as today. An AM that supplies
amoptions owns the option set entirely, and the bytea it returns,
stored verbatim in Relation->rd_options, dictates the layout its
other callbacks read.

DefineRelation, ATExecSetRelOptions, RelationParseRelOptions and
extractRelOptions all route through table_reloptions() for
RELKIND_RELATION/RELKIND_MATVIEW. The AM is resolved from the USING
clause, the partition parent, or default_table_access_method at
CREATE TABLE, and from rel->rd_tableam at ALTER TABLE SET -- except
a SET ACCESS METHOD queued in the same statement uses the new AM's
parser instead, so "SET ACCESS METHOD x, SET (foo = bar)" works when
foo belongs to x. SET ACCESS METHOD also revalidates the relation's
final reloptions against the new AM once other subcommands in the
statement have run, turning a reloption the new AM doesn't accept
into an immediate error instead of a silent drop at the next
relcache load; RESET in the same statement clears it.

Core code that reads StdRdOptions fields straight out of rd_options
(RelationGetFillFactor and friends, plus direct casts in
vacuum.c/index.c) now checks RelationHasStdRdOptions() first, since
an AM-owned bytea isn't guaranteed to be StdRdOptions-shaped. An AM
can opt back into all of these at once via
TableAmRoutine.has_std_options_prefix, set when its own struct embeds
a full StdRdOptions as its first member -- and it must register every
field these call sites read, not just the ones it cares about, since
an embedded field it never registers is left zeroed rather than at
its real default (wrong for e.g. parallel_workers, default -1).

add_reloption_to_kind(name, kind) extends an existing reloption's
kind bitmask, so an AM can accept a core-registered option
(fillfactor, autovacuum_*, ...) without redeclaring it; this is how
an AM fills in the embedded StdRdOptions fields above. One exception:
autovacuum's own scheduling always parses via the standard heap
parser regardless of AM, so it only ever sees the standard
autovacuum_* names, never an AM's own amoptions -- consulting each
relation's AM during autovacuum's periodic scan isn't worth a
catalog lookup per relation for a capability nothing needs.

src/test/modules/dummy_table_am demonstrates the API: it copies
heap's TableAmRoutine wholesale and overrides two callbacks.
amoptions parses a mix of inherited standard options and its own
into a struct embedding StdRdOptions. relation_toast_am also needs
overriding, since heap's default returns the calling relation's own
AM oid -- correct for heap, wrong for an AM that just inherits it,
whose TOAST tables would then belong to that AM instead of heap.
---
 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              | 170 +++++++++++-
 src/backend/commands/vacuum.c                 |   7 +-
 src/backend/utils/cache/relcache.c            |   4 +-
 src/include/access/reloptions.h               |   3 +
 src/include/access/tableam.h                  |  60 +++++
 src/include/utils/rel.h                       |  29 +-
 src/test/modules/Makefile                     |   1 +
 src/test/modules/dummy_table_am/Makefile      |  20 ++
 src/test/modules/dummy_table_am/README        |  21 ++
 .../dummy_table_am/dummy_table_am--1.0.sql    |  13 +
 .../modules/dummy_table_am/dummy_table_am.c   | 253 ++++++++++++++++++
 .../dummy_table_am/dummy_table_am.control     |   5 +
 .../dummy_table_am/expected/reloptions.out    | 184 +++++++++++++
 src/test/modules/dummy_table_am/meson.build   |  33 +++
 .../modules/dummy_table_am/sql/reloptions.sql | 119 ++++++++
 src/test/modules/meson.build                  |   1 +
 20 files changed, 1111 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/reloptions.out
 create mode 100644 src/test/modules/dummy_table_am/meson.build
 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 453395c5c73..3d123dc17c8 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -803,6 +803,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 3e832c3797e..ccf58f53110 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"
@@ -749,6 +750,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
@@ -1516,8 +1555,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:
@@ -2187,6 +2229,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 9407c357f27..b08aa11b206 100644
--- a/src/backend/catalog/index.c
+++ b/src/backend/catalog/index.c
@@ -2871,7 +2871,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)
 				update_stats = false;
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index eec09ba1ded..24e8b787f0b 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -693,9 +693,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,
+								Oid newAccessMethodId);
 static void ATExecEnableDisableTrigger(Relation rel, const char *trigname,
 									   char fires_when, bool skip_system, bool recurse,
 									   LOCKMODE lockmode);
@@ -961,6 +963,41 @@ 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));
+				else
+					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);
 	}
@@ -4924,6 +4961,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);
 }
@@ -5595,7 +5644,17 @@ 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; tell ATExecSetRelOptions to validate
+			 * against that AM rather than the relation's current AM.  This
+			 * lets a user write ALTER TABLE t SET ACCESS METHOD x, SET (foo =
+			 * bar) where foo is recognised by x but not by the current AM.
+			 */
+			ATExecSetRelOptions(rel, (List *) cmd->def, cmd->subtype, lockmode,
+								tab->chgAccessMethod ? tab->newAccessMethod : InvalidOid);
 			break;
 		case AT_EnableTrig:		/* ENABLE TRIGGER name */
 			ATExecEnableDisableTrigger(rel, cmd->name,
@@ -16885,12 +16944,92 @@ 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);
+
+		/*
+		 * If the new AM has no option parser of its own, table_reloptions
+		 * falls back to the standard heap parser, which accepts whatever the
+		 * old AM accepted (every other AM in core uses the same StdRdOptions
+		 * today), so there is nothing to re-check.
+		 */
+		if (amoptions == NULL)
+			continue;
+
+		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.
+ *
+ * newAccessMethodId, if valid, names the table access method whose option
+ * parser should validate the resulting reloptions.  This is used when SET
+ * ACCESS METHOD is queued in the same ALTER TABLE so that the new options
+ * are checked against the AM the relation will use after the statement
+ * finishes, not the AM it has now.  Pass InvalidOid to use the relation's
+ * current access method.
  */
 static void
 ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
-					LOCKMODE lockmode)
+					LOCKMODE lockmode, Oid newAccessMethodId)
 {
 	Oid			relid;
 	Relation	pgclass;
@@ -16942,7 +17081,30 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
 	{
 		case RELKIND_RELATION:
 		case RELKIND_MATVIEW:
-			(void) heap_reloptions(rel->rd_rel->relkind, newOptions, true);
+			{
+				amoptions_function amoptions;
+
+				if (OidIsValid(newAccessMethodId))
+				{
+					HeapTuple	amtup;
+					Form_pg_am	amform;
+
+					amtup = SearchSysCache1(AMOID,
+											ObjectIdGetDatum(newAccessMethodId));
+					if (!HeapTupleIsValid(amtup))
+						elog(ERROR, "cache lookup failed for access method %u",
+							 newAccessMethodId);
+					amform = (Form_pg_am) GETSTRUCT(amtup);
+					amoptions = GetTableAmRoutine(amform->amhandler)->amoptions;
+					ReleaseSysCache(amtup);
+				}
+				else
+					amoptions = (rel->rd_tableam ?
+								 rel->rd_tableam->amoptions : NULL);
+
+				(void) table_reloptions(amoptions, 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 99d0db82ed7..68d1a5369fb 100644
--- a/src/backend/commands/vacuum.c
+++ b/src/backend/commands/vacuum.c
@@ -2185,7 +2185,7 @@ vacuum_rel(Oid relid, RangeVar *relation, VacuumParams params,
 	{
 		StdRdOptIndexCleanup vacuum_index_cleanup;
 
-		if (rel->rd_options == NULL)
+		if (!RelationHasStdRdOptions(rel))
 			vacuum_index_cleanup = STDRD_OPTION_VACUUM_INDEX_CLEANUP_AUTO;
 		else
 			vacuum_index_cleanup =
@@ -2216,7 +2216,7 @@ vacuum_rel(Oid relid, RangeVar *relation, VacuumParams params,
 	 * Check if the vacuum_max_eager_freeze_failure_rate table storage
 	 * parameter was specified. This overrides the GUC value.
 	 */
-	if (rel->rd_options != NULL &&
+	if (RelationHasStdRdOptions(rel) &&
 		((StdRdOptions *) rel->rd_options)->vacuum_max_eager_freeze_failure_rate >= 0)
 		params.max_eager_freeze_failure_rate =
 			((StdRdOptions *) rel->rd_options)->vacuum_max_eager_freeze_failure_rate;
@@ -2227,7 +2227,8 @@ vacuum_rel(Oid relid, RangeVar *relation, VacuumParams params,
 	 */
 	if (params.truncate == VACOPTVALUE_UNSPECIFIED)
 	{
-		StdRdOptions *opts = (StdRdOptions *) rel->rd_options;
+		StdRdOptions *opts = RelationHasStdRdOptions(rel) ?
+			(StdRdOptions *) rel->rd_options : NULL;
 
 		if (opts && opts->vacuum_truncate != PG_TERNARY_UNSET)
 		{
diff --git a/src/backend/utils/cache/relcache.c b/src/backend/utils/cache/relcache.c
index e19f0d3e51c..958c13f8338 100644
--- a/src/backend/utils/cache/relcache.c
+++ b/src/backend/utils/cache/relcache.c
@@ -481,9 +481,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 e8cb7f7a627..1282bccc77f 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,
@@ -248,6 +249,8 @@ extern void *build_local_reloptions(local_relopts *relopts, Datum options,
 extern bytea *default_reloptions(Datum reloptions, bool validate,
 								 relopt_kind kind);
 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 c13f05d39db..332b70a1e11 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"
@@ -324,6 +325,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, 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 cd1e92f2302..04caa15707a 100644
--- a/src/include/utils/rel.h
+++ b/src/include/utils/rel.h
@@ -361,12 +361,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))
 
 /*
@@ -374,7 +395,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))
 
 /*
@@ -397,7 +418,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)
@@ -408,7 +429,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 0a74ab5c86f..223005fdf98 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..94837dff392
--- /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
+
+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..a234a1f107f
--- /dev/null
+++ b/src/test/modules/dummy_table_am/README
@@ -0,0 +1,21 @@
+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:
+
+  - "fillfactor"     (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)
+
+Standard heap options such as parallel_workers, autovacuum_*, and
+toast_tuple_target are intentionally NOT accepted, to exercise the
+"AM rejects an unknown option" path in ALTER TABLE ... SET ACCESS
+METHOD revalidation.
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..2e295b95845
--- /dev/null
+++ b/src/test/modules/dummy_table_am/dummy_table_am--1.0.sql
@@ -0,0 +1,13 @@
+/* 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';
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..1fba6057675
--- /dev/null
+++ b/src/test/modules/dummy_table_am/dummy_table_am.c
@@ -0,0 +1,253 @@
+/*-------------------------------------------------------------------------
+ *
+ * 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.
+ *
+ * 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: 8 inherited standard options + 4 of our own */
+static relopt_parse_elt dt_relopt_tab[12];
+
+/* Kind of relation options for dummy table */
+static relopt_kind dt_relopt_kind;
+
+typedef enum DummyTableEnum
+{
+	DUMMY_TABLE_ENUM_ONE,
+	DUMMY_TABLE_ENUM_TWO,
+}			DummyTableEnum;
+
+/*
+ * Dummy table options.
+ *
+ * "std" embeds the full standard heap layout: every option that heap's own
+ * default_reloptions() understands (fillfactor, toast_tuple_target,
+ * parallel_workers, vacuum_index_cleanup, vacuum_truncate,
+ * vacuum_max_eager_freeze_failure_rate, autovacuum_enabled,
+ * user_catalog_table) is inherited by calling add_reloption_to_kind() on
+ * the matching name, and each is registered here so that build_reloptions()
+ * fills it with the option's own catalog default when the user does not
+ * set it explicitly -- an embedded StdRdOptions field that is never
+ * registered stays zeroed, which is not the right "unset" value for
+ * several of these (e.g. parallel_workers and
+ * vacuum_max_eager_freeze_failure_rate both use -1).  Because "std" is a
+ * full StdRdOptions and every one of its fields is populated this way, we
+ * can tell core code it's safe to keep reading them directly by setting
+ * TableAmRoutine.has_std_options_prefix (see dthandler() below).
+ *
+ * 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;
+
+static relopt_enum_elt_def dummyTableEnumValues[] =
+{
+	{"one", DUMMY_TABLE_ENUM_ONE},
+	{"two", DUMMY_TABLE_ENUM_TWO},
+	{(const char *) NULL}		/* list terminator */
+};
+
+PG_FUNCTION_INFO_V1(dthandler);
+
+/*
+ * 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.  See
+	 * the comment on DummyTableOptions.std for why we register all of
+	 * them rather than just fillfactor.
+	 */
+	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("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_BOOL;
+	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++;
+}
+
+/*
+ * 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));
+}
+
+/*
+ * 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);
+}
+
+void
+_PG_init(void)
+{
+	create_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/reloptions.out b/src/test/modules/dummy_table_am/expected/reloptions.out
new file mode 100644
index 00000000000..5d38767c528
--- /dev/null
+++ b/src/test/modules/dummy_table_am/expected/reloptions.out
@@ -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;
+                                   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)
+
+-- 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;
+-- 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"
+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..ad3fa2410cc
--- /dev/null
+++ b/src/test/modules/dummy_table_am/meson.build
@@ -0,0 +1,33 @@
+# 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',
+    ],
+  },
+}
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..97bc8479527
--- /dev/null
+++ b/src/test/modules/dummy_table_am/sql/reloptions.sql
@@ -0,0 +1,119 @@
+-- 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;
+
+-- 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;
+
+-- 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);
+
+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 4bca42bb370..07b6b24a5ab 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

