diff --git a/src/backend/replication/logical/reorderbuffer.c b/src/backend/replication/logical/reorderbuffer.c
index 11139a910b8..6c0125818cd 100644
--- a/src/backend/replication/logical/reorderbuffer.c
+++ b/src/backend/replication/logical/reorderbuffer.c
@@ -145,6 +145,7 @@ typedef struct ReorderBufferTupleCidEnt
 	CommandId	cmin;
 	CommandId	cmax;
 	CommandId	combocid;		/* just for debugging */
+	TransactionId subxid;		/* subtransaction that wrote this entry */
 } ReorderBufferTupleCidEnt;
 
 /* Virtual file descriptor with file offset tracking */
@@ -1878,16 +1879,60 @@ ReorderBufferBuildTupleCidHash(ReorderBuffer *rb, ReorderBufferTXN *txn)
 			ent->cmin = change->data.tuplecid.cmin;
 			ent->cmax = change->data.tuplecid.cmax;
 			ent->combocid = change->data.tuplecid.combocid;
+			ent->subxid = change->data.tuplecid.subxid;
 		}
-		else
+		else if (ent->cmin != change->data.tuplecid.cmin)
 		{
 			/*
-			 * Maybe we already saw this tuple before in this transaction, but
-			 * if so it must have the same cmin.
+			 * The same TID appears with a different cmin.  This happens when
+			 * a subtransaction inserts a catalog tuple, then a ROLLBACK TO
+			 * SAVEPOINT aborts that subtransaction, and a later operation
+			 * reuses the same TID (after page pruning reclaims the dead
+			 * tuple's slot).  Both xl_heap_new_cid records remain in the WAL
+			 * under the top-level xid.
+			 *
+			 * We resolve this by checking which entry's subtransaction
+			 * aborted.  The entry from the aborted subtransaction is stale
+			 * and should be discarded.
 			 */
-			Assert(ent->cmin == change->data.tuplecid.cmin);
-
+			if (TransactionIdDidAbort(ent->subxid))
+			{
+				/* Existing entry is from an aborted subtxn; replace it. */
+				ent->cmin = change->data.tuplecid.cmin;
+				ent->cmax = change->data.tuplecid.cmax;
+				ent->combocid = change->data.tuplecid.combocid;
+				ent->subxid = change->data.tuplecid.subxid;
+			}
+			else if (TransactionIdDidAbort(change->data.tuplecid.subxid))
+			{
+				/* New entry is from an aborted subtxn; skip it. */
+			}
+			else
+			{
+				/*
+				 * Neither subtransaction aborted — this shouldn't happen.
+				 * Keep the existing entry but log a warning.
+				 */
+				elog(WARNING, "tuplecid cmin mismatch with no aborted "
+					 "subtransaction: rel %u/%u/%u tid (%u,%u) "
+					 "existing cmin %u (subxid %u) "
+					 "new cmin %u (subxid %u)",
+					 key.rlocator.spcOid,
+					 key.rlocator.dbOid,
+					 key.rlocator.relNumber,
+					 ItemPointerGetBlockNumber(&key.tid),
+					 ItemPointerGetOffsetNumber(&key.tid),
+					 ent->cmin, ent->subxid,
+					 change->data.tuplecid.cmin,
+					 change->data.tuplecid.subxid);
+			}
+		}
+		else
+		{
 			/*
+			 * Same cmin — this is the normal case where the same tuple is
+			 * seen multiple times (e.g. insert then update).  Update cmax.
+			 *
 			 * cmax may be initially invalid, but once set it can only grow,
 			 * and never become invalid again.
 			 */
@@ -3440,7 +3485,8 @@ void
 ReorderBufferAddNewTupleCids(ReorderBuffer *rb, TransactionId xid,
 							 XLogRecPtr lsn, RelFileLocator locator,
 							 ItemPointerData tid, CommandId cmin,
-							 CommandId cmax, CommandId combocid)
+							 CommandId cmax, CommandId combocid,
+							 TransactionId subxid)
 {
 	ReorderBufferChange *change = ReorderBufferAllocChange(rb);
 	ReorderBufferTXN *txn;
@@ -3452,6 +3498,7 @@ ReorderBufferAddNewTupleCids(ReorderBuffer *rb, TransactionId xid,
 	change->data.tuplecid.cmin = cmin;
 	change->data.tuplecid.cmax = cmax;
 	change->data.tuplecid.combocid = combocid;
+	change->data.tuplecid.subxid = subxid;
 	change->lsn = lsn;
 	change->txn = txn;
 	change->action = REORDER_BUFFER_CHANGE_INTERNAL_TUPLECID;
diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c
index adf18c397db..3c9eba3dbaa 100644
--- a/src/backend/replication/logical/snapbuild.c
+++ b/src/backend/replication/logical/snapbuild.c
@@ -700,7 +700,7 @@ SnapBuildProcessNewCid(SnapBuild *builder, TransactionId xid,
 	ReorderBufferAddNewTupleCids(builder->reorder, xlrec->top_xid, lsn,
 								 xlrec->target_locator, xlrec->target_tid,
 								 xlrec->cmin, xlrec->cmax,
-								 xlrec->combocid);
+								 xlrec->combocid, xid);
 
 	/* figure out new command id */
 	if (xlrec->cmin != InvalidCommandId &&
diff --git a/src/include/replication/reorderbuffer.h b/src/include/replication/reorderbuffer.h
index fa0745552f8..0072c7e5aa1 100644
--- a/src/include/replication/reorderbuffer.h
+++ b/src/include/replication/reorderbuffer.h
@@ -146,6 +146,7 @@ typedef struct ReorderBufferChange
 			CommandId	cmin;
 			CommandId	cmax;
 			CommandId	combocid;
+			TransactionId subxid;
 		}			tuplecid;
 
 		/* Invalidation. */
@@ -748,7 +749,9 @@ extern void ReorderBufferAddNewCommandId(ReorderBuffer *rb, TransactionId xid,
 extern void ReorderBufferAddNewTupleCids(ReorderBuffer *rb, TransactionId xid,
 										 XLogRecPtr lsn, RelFileLocator locator,
 										 ItemPointerData tid,
-										 CommandId cmin, CommandId cmax, CommandId combocid);
+										 CommandId cmin, CommandId cmax,
+										 CommandId combocid,
+										 TransactionId subxid);
 extern void ReorderBufferAddInvalidations(ReorderBuffer *rb, TransactionId xid, XLogRecPtr lsn,
 										  Size nmsgs, SharedInvalidationMessage *msgs);
 extern void ReorderBufferAddDistributedInvalidations(ReorderBuffer *rb, TransactionId xid,
