Which was extremely frustrating. After an hour of clearing cache, hitting Shift-F5 and lots of online search, I asked about it in the forum. Got an answer from Pavel-ww very quickly: in the advanced options, you need to choose the Module Style "NoCard" in order for a custom class to work.

That worked, but it didn't seem very logical. So I did some further research.

In Joomla, module styles define the HTML output for modules.

These styles control the output for the module title, heading and class suffixes.

In J!3, module styles were managed through a unique file named modules.php located inside the /html folder of each template.
Starting in J!4, module styles are managed in layout files - one for each module style. The HTML structure for a style is defined in /templates/cassiopeia/html/layouts/chromes/[style].php.

I tested a custom module with each Module Style, plus a custom class "mymoduleclass". It turns out that in fact they all retain a custom class, except "None" (and "Outline", but that makes sense).
What's also nice: each module has a unique ID.

Here's what I found.

System Style: HTML5

Picks up the custom class.

Title and content in separate divs.

The title gets an <h3> tag (you can set that tag in the options).

<div class="moduletable  mymoduleclass">
<h3>Testing Module Styles</h3>
<div id="mod-custom119" class="mod-custom custom">
<p>Module content goes here</p>
</div>
</div>

Style: None

No custom class. No title either.

No styling (though Cassiopeia adds a margin-top).

<div id="mod-custom119" class="mod-custom custom">
<p>Module content goes here</p>
</div>

Style: Outline

Adds position and style, used for showing module positions in the front-end (?tp=1).

Style: Table

Makes an actual table... The content goes inside a div, inside a <td>.

Custom class is added.

Title goes into a <th> (without a header tag).

<table class="moduletable  mymoduleclass">
<tbody>
<tr>
<th>Testing Module Styles</th>
</tr>
<tr>
<td>
<div id="mod-custom119" class="mod-custom custom">
<p>Module content goes here</p>
</div>
</td>
</tr>
</tbody>
</table>

Style: Cassiopeia Card

Keeps the custom class.

Header and content in separate divs.

Extra div for "card-body".

Adds some classes for additional Cassiopeia styling.

<div class="sidebar-right card  mymoduleclass">
<h3 class="card-header ">Testing Module Styles</h3>
<div class="card-body">
<div id="mod-custom119" class="mod-custom custom">
<p>Module content goes here</p>
</div>
</div>
</div>

Style: Cassiopeia Nocard

Keeps custom class.

Header and content in separate divs.

<div class="sidebar-right no-card  mymoduleclass">
<h3>Testing Module Styles</h3>
<div id="mod-custom119" class="mod-custom custom">
<p>Module content goes here</p>
</div>
</div>