The concept: a name in the field is a contract you can’t take back
MAM Suite is the no-code WordPress-to-native-mobile-app builder — the “WooCommerce of mobile apps.” That model has a consequence most WordPress plugins never have to reckon with: the code you ship runs in two places you do not control at the same time. A customer’s iOS and Android app is compiled, submitted to the App Store and Play Store, and installed on phones; the WordPress site that feeds it runs mam-main plus a portfolio of sibling plugins. Today there are roughly 2,000 enrolled customer sites and an unknown, ever-growing number of compiled apps in users’ hands.
When two independently-deployed systems talk to each other, the names they use to talk become an API. The mobile app calls an AJAX action by its literal string name. A customer’s wp_options row stores an enrollment ID under a literal option key. A sibling plugin hooks a filter by its literal hook name. None of these callers can be patched the moment you rename something — the app is already on a phone, the option is already in 2,000 databases, the sibling plugin copy may be an unmaintained version sitting on a customer’s server.
So MAM Suite treats a specific set of these names as frozen public contracts: their internal implementation may change freely, but their public-facing names and shapes do not. The discipline that follows from this has a four-word summary that recurs throughout the codebase:
When in doubt: wrap, don’t rename.
This article explains what is frozen, why each category is frozen, what “frozen” actually permits and forbids, and how the migration framework gives you a safe path when a rename is genuinely unavoidable.
Why renaming is uniquely dangerous here
In a normal codebase you rename a symbol and update every call site in the same commit. The rename is atomic. The danger in MAM Suite is that the call sites are not all in your repository and not all updatable at deploy time:
- Compiled mobile clients call AJAX action names that were baked into a binary and shipped through Apple/Google review. You cannot reach back into a phone and change the string. Old app versions linger for years.
- ~2K
wp_optionsdatabases hold real values under specific keys — enrollment IDs, button configs, store credentials. Renaming the key in code makes every existing value invisible to the new code. - Sibling plugins extend
mam-mainexclusively through filters and actions (mam-mainnever imports their classes). A renamed hook silently drops every subscriber — no error, just missing behavior. - App-store-bound identifiers (bundle IDs, team IDs, version/build numbers) are tied to submissions Apple and Google already accepted. Renaming the option that stores a bundle ID risks orphaning the customer’s published app entirely.
A rename that “works on my machine” can therefore silently break behavior across thousands of sites and apps, with no exception thrown and no obvious failure — exactly the kind of bug that surfaces weeks later as a support ticket nobody can reproduce.
What is frozen, and why each category is frozen
mam-main enumerates its frozen contracts explicitly — they are documented as a reference inventory, not discovered by accident. There are four categories.
1. AJAX actions — the mobile API surface
The mobile app’s entire conversation with WordPress flows through a small set of AJAX actions. The primary one builds the JSON payload that describes every screen, button, and content element the app renders:
local_app_get_phone_data— primary mobile API; every deployed mobile client calls this literal name.mam_get_phone_data— the modern alias, already in the field.mam_user_roles_cred_handler— legacy login path; older mobile clients call this name.mam_setcron_processor— the URL FastCron pings.
Endpoints prefixed local_app_* are public-contract names by convention. The handler class behind an action can be renamed, refactored, or replaced; the action string the app dials cannot. The local_app_* prefix itself predates the mam_* naming convention and survives precisely because the apps already use it.
2. Option keys — values that live in 2,000 databases
Every frozen option key is frozen because a real value already sits under that exact key on enrolled sites:
local-app-account_code— the customer enrollment ID ~2K sites populated. Now centralized throughMAM_Account_Code_Manager, whose canonical value has since moved tomam-account-code; the original key stays frozen and alias-backed, so external and mobile readers of the old name keep resolving (see the migration example below).local-app-button-array*— the per-role serialized button arrays customer admins have built up over years (the global list plus_subscriber,_administrator,_anonymous, and custom-role variants).local-app-onboarding-status— setup-wizard progress.- App-store-bound keys such as the iOS/Android bundle IDs, team/key IDs, version numbers, and build numbers — these are tied to submissions the stores already accepted.
The local-app-* keys date to before the mam_* rename convention; the ios_* / android_* keys are tied to store submissions. Both classes mean a rename is not a code change — it is a coordinated data migration across every site.
3. Hooks — the extension contract sibling plugins depend on
Sibling plugins don’t share code with mam-main; they share hook names. That makes the hook surface a contract with a large, partly-unmaintained set of subscribers. A few of the load-bearing ones:
mam_get_phone_data_before_send— the main injection point for the mobile JSON payload, with roughly 70 subscribers across the suite. Every no-code feature plugin (geodirectory, chat, special offers, IAP, forms, etc.) hooks here.mam_notification_send_message— the primary notification entry point. It is fired both as an action and, for unmaintained legacy callers, still registered as a filter — a deliberate compatibility shim rather than dead code.mam_app_settings_get_setting/mam_app_settings_set_setting— the settings cascade, with 100+ call sites.mam_tab_manager(~43 subscribers),mam_notification_list(68+ registrations),mam_cron_manager(25+), and per-button/per-role/per-form dynamic hooks.
Renaming any of these doesn’t throw — it silently unsubscribes everyone. That silence is what makes hook renames especially hazardous.
4. DB tables — six owned tables, renamed only through migration
mam-main owns six tables (mail queue, mail attachments, the unified PN+SMS queue, notification history, phone tokens, debug items). They were renamed from wp_tsl_* to wp_mam_* — but not with a raw, ad-hoc RENAME TABLE. The rename went through MAM_Migration_Tasks::rename_table(), which runs once per site behind an idempotency guard and a collision check (if both the old and new tables exist it bails rather than clobbering), and mam-main’s repository classes were updated to the new names in the same release. Unlike option keys, tables get no alias — there is no table-level equivalent of the pre_option filter — but they don’t need one: these six are owned by mam-main and reached only through its repositories, never by raw name from sibling plugins. Renaming one by hand instead, with no guard, is what would break things — a re-run or a partially-migrated site could collide or drop data with no error.
What “frozen” actually permits — it is not “never touch it”
Frozen does not mean ossified. The whole point of the discipline is that you can keep evolving the implementation as long as the public face holds still. The line is between the contract and the implementation behind it:
| You can… | You cannot… |
|---|---|
| Refactor the implementation behind a frozen contract | Rename the contract |
Wrap a frozen option in a manager class (e.g., MAM_Account_Code_Manager) |
Move a frozen option to a custom table without a migration |
| Add new fields to a frozen JSON shape (additive only) | Remove or rename existing fields in that shape |
| Add new hooks alongside frozen ones | Rename or remove a frozen hook |
| Rename the internal handler class behind an AJAX action | Rename the AJAX action string itself |
Rename a frozen table through MAM_Migration_Tasks::rename_table() (idempotent, collision-checked) |
Rename a frozen table with a raw, ad-hoc RENAME TABLE / ALTER TABLE |
MAM_Account_Code_Manager is the canonical example, and it shows both halves of the discipline. Every read, write, and delete of the enrollment ID now routes through one class instead of scattered get_option() calls, and that class quietly falls back to the legacy key and preserves the LOCALHOST offline sentinel. The canonical value did move — it now lives under mam-account-code, migrated from local-app-account_code — but the old key stays a live frozen contract: an alias filter registered in mam-main.php transparently resolves any external read of local-app-account_code to the new value. Wrapping centralized the access; the migration-with-alias (below) is what let the key move without breaking a single field-deployed reader.
The phone-data pipeline shows the same move at JSON scale. Internally it is a clean four-phase pipeline (MAM_Phone_Data_Pipeline) carrying a typed MAM_Phone_Data_Context value object. But at the boundary where the legacy mam_get_phone_data_before_send filter fires, the context is converted to and from a plain array — so the ~70 existing subscribers keep working untouched while the internals were rebuilt around them. Wrapping, not renaming, is what made that refactor safe.
How to evolve safely when a rename is unavoidable
Sometimes you genuinely must rename — the tsl_* → mam_* sweep was exactly this case. The escape hatch is the migration framework, MAM_Migration_Tasks (in includes/migrations/). It is the only sanctioned way to rename a frozen contract, and it works by keeping the old name alive rather than severing it.
The key idea is aliasing: register_option_alias() installs a pre_option / pre_update_option filter pair so that legacy callers reading the old key transparently get the new value — forever, including code in unmaintained customer-site plugin copies. The old name never has to be hunted down and updated; it is redirected.
The framework’s verified surface includes:
// Redirect reads/writes from an old key to a new one (the safe default).
MAM_Migration_Tasks::register_option_alias( 'old-key', 'new-key' );
// Batch many renames under one tracked migration id.
MAM_Migration_Tasks::register_option_renames(
'migration_id_for_this_sweep',
array(
'old-key-1' => 'new-key-1',
'old-key-2' => 'new-key-2',
),
'human-readable description'
);
// One-time copy-and-delete with an idempotency guard.
MAM_Migration_Tasks::rename_option( 'old', 'new' );
// RENAME TABLE with a collision + idempotency guard — never raw SQL.
// Pass names WITHOUT the wp_ prefix; rename_table() adds $wpdb->prefix internally.
MAM_Migration_Tasks::rename_table( 'tsl_mail_queue', 'mam_mail_queue' );
// Bulk postmeta key rename.
MAM_Migration_Tasks::rename_postmeta_key( 'old', 'new' );
Note the argument order on register_option_renames(): the migration id comes first, then the old→new rename map, then an optional human-readable description. It registers the aliases immediately (on every page load) and schedules the one-time copy-and-delete as a tracked migration, so both the backward-compat path and the eventual data move are handled by one call.
This is what makes “frozen” sustainable rather than paralyzing: a frozen contract is not one you can never change, it is one you may only change additively or through a migration that preserves the old path.
How to verify you didn’t break a contract
Because contract breaks are silent, the suite leans on a regression harness rather than runtime errors. tests/snapshot/snapshot-phone-data.php captures the JSON output of the phone-data pipeline for known fixtures (anonymous, logged-in user, cloning admin, cache hit) and diffs new output against a baseline. It was run on the DB and option rename PRs specifically to prove zero behavior change at the contract boundary. When you touch anything near a frozen surface, a snapshot diff that comes back empty is the evidence that the contract held.
How this connects to the rest of MAM Suite
- The sibling-plugin extension model depends entirely on frozen hooks —
mam-mainnever imports sibling classes (see Why MAM extends through filters only and How sibling plugins extend the filter-driven architecture). The freeze on hook names likemam_get_phone_data_before_send,mam_notification_list, andmam_tab_manageris what lets dozens of plugins evolve independently without coordinating releases. - The settings cascade (
mam_app_settings_get_setting/_set_setting) and the button array storage (local-app-button-array*) are frozen surfaces a feature developer touches constantly — always through the cascade-aware filters, never by hand-building an array intoupdate_option, so validation and cache invalidation still run. - Mobile JSON keys added by feature plugins are themselves part of the frozen mobile contract once shipped.
mam-contact-form, for instance, contributes field slugs (name,email,phone, …) into the Gravity-Forms-shaped payload; the mobile app keys submissions on those slugs, so they inherit the same rename-prohibition the core contracts have.
The throughline: in a platform whose code runs simultaneously on phones you can’t patch and 2,000 databases you can’t atomically migrate, a name is a promise. You keep the promise by wrapping the implementation, adding alongside rather than renaming, and reaching for the migration framework — with aliasing — on the rare occasions a name truly must change.
Related
- Frozen public contracts reference — the full per-name inventory of AJAX actions, option keys, hooks, and tables.
- Button array storage (`local-app-button-array`)* — a worked example of a heavily-frozen option family.
- Architecture overview — where the phone-data pipeline, dispatcher, and migration framework sit.
- Settings cascade overview — the filter-based read/write path for frozen settings.
- Sibling plugin extension points — how the frozen hook surface lets plugins extend
mam-main.
