Darstellung
Installations, staging and environments
Identity, environment signals, and why the plugin decides none of it.
Installation limits
Per the product matrix:
| Plan | Production sites | Staging / development |
|---|---|---|
| Free | 1 | none |
| Pro | 1 | 1 associated |
| Pro+ | 3 | 1 per production site |
| Business | unlimited | associated |
The plugin does not enforce any of this. It reports what it sees and the API decides whether an activation is allowed. There is no local activation counter, no local site list and no local limit check. A license_limit_reached response is surfaced to the administrator with guidance to remove an installation in the dashboard.
Installation identity
installation_uuid, a UUID v4 from Support\Uuid, stored in ad_form_license_identity.
| Property | Behaviour |
|---|---|
| Generated | Once, seeded in Activator::activate_site() |
| Regenerated | Never on a normal request |
| Scope | Per site, including per blog on multisite |
| Repair | Generated lazily if the option is missing, for installs that predate licensing |
| Validation | A malformed stored value is replaced rather than trusted |
The UUID is an identifier, not a secret. It is safe in logs and support tickets.
The credential
After activation the platform issues an installation_token. From then on the license key is never transmitted again. The token is confidential:
- never rendered into HTML
- never placed in
window.adFormBuilderor any other JavaScript - never returned by a REST endpoint
- never logged, not even partially — only as
****, or as a 12-character fingerprint for correlating log lines - stripped by
LicenseLoggerbefore it can reachLogger::redact()
InstallationIdentity::forget_credentials() drops the token, installation id, license id and masked key but keeps the UUID, so a reconnect maps to the same installation instead of consuming another activation slot.
Lifecycle scenarios
| Scenario | Plugin behaviour | Who decides |
|---|---|---|
| First activation | Generate the UUID, activate, store the token | API |
| Normal request | Read only | — |
| Plugin deactivated | Nothing. Cron event cleared, license state kept | — |
| Plugin reactivated | Existing UUID reused, entitlements restored from cache | — |
| Plugin deleted, data kept | Local options remain | — |
| Plugin deleted, data deletion enabled | Local options removed, API not contacted | Customer, in the dashboard |
http to https | Report the new URL. No local comparison | API |
www to non-www | Report the new URL | API |
| Domain migration | Same UUID, new URL, reported | API |
| Backup restore | UUID returns with the backup, which is correct | — |
| Site clone | The clone carries the same UUID, reported alongside its own URL | API |
| Staging refresh from production | Same as a clone | API |
Site URL changes are never compared locally
There is no string comparison of site_url anywhere in the licensing code. site_url and home_url are reported verbatim on every activation and validation, and reconciliation is the API's problem.
That is deliberate. Naive URL equality is the classic way licensing breaks a working production site: an SSL migration, a CDN, a www redirect or a multisite domain mapping all change the string without changing the installation. Recorded as risk R-11 in docs/EXISTING_ARCHITECTURE_AUDIT.md. Asserted in EnvironmentDetectorTest::test_the_scheme_is_not_part_of_the_signal.
Environment detection
EnvironmentDetector reports signals. It never returns a verdict the API is expected to accept.
Signals collected
| Signal | Source |
|---|---|
declared_type | wp_get_environment_type() |
resolved_type | the plugin's best local guess |
host | host component of site_url |
host_matches_local | localhost, 127.0.0.1, .local, .test, .localhost, .invalid, .example |
host_matches_stage | label-boundary match on staging, stage, dev, development, test, testing, preview, sandbox, demo, qa, uat |
managed_host_match | known managed-host staging domains |
wp_debug | WP_DEBUG |
script_debug | SCRIPT_DEBUG |
env_constant_set | whether WP_ENVIRONMENT_TYPE is defined |
is_multisite, blog_id, network_home_url | network context |
Matching is label-bounded
A staging marker only counts at a hostname label boundary, so development.example.com, shop-staging.example.com and dev-shop.example.com match, but bestdevices.com does not. Substring matching would misclassify real production sites, which is worse than missing a staging site — the API has better evidence anyway.
.test and .local are reserved local TLDs and are reported as local. That caught a wrong test fixture during development, which is the behaviour working as intended.
Declared type does not override a staging host
If wp_get_environment_type() says production but the host says staging, the plugin reports staging. A site operator claiming production must not be able to silence the host signal, since they control the PHP that returns it.
The reverse is honoured: a declared staging, development or local is believed even on a production-looking host, because that direction can only reduce what the customer is entitled to.
Why the plugin cannot be trusted about its own environment
The site operator controls PHP. wp_get_environment_type() is a function they can define the return value of. Every signal above is therefore a hint.
The API is expected to make the real decision using evidence the plugin cannot forge:
- registrable domain rather than full hostname
- the link to a known production installation of the same license
- activation history and timing
- domain patterns across the licensee's installations
- anomalies, such as many "staging" activations on unrelated registrable domains
Recorded in docs/SECURITY_LICENSING.md as part of the trust boundary.
Multisite
The plugin is uniformly per-site. Core\WpOptions wraps get_option exclusively; there is no *_site_option call anywhere in src/. Activator installs the schema per blog and hooks wp_initialize_site; Uninstaller purges per blog.
| Aspect | Policy |
|---|---|
| Network activation | Supported. Each blog activates independently |
| Site-specific activation | Supported, identical code path |
| License scope | Per blog |
| Installation UUID scope | Per blog |
| Token scope | Per blog |
| Network admin screen | Not implemented. Additive later, not a scope change |
Each blog reports is_multisite, blog_id and network_home_url, so the API can recognise that twenty installations belong to one network and bill them as one customer if the product decides to. That decision belongs on the platform, not in the plugin.
Rejected alternative: a network-wide license in wp_sitemeta. It would be the only network-scoped state in the plugin, would break the per-blog uninstall path, and would let one blog's administrator revoke a sibling's entitlements. Recorded as risk R-10 in the audit.
Data transmitted
Exactly these fields, asserted by RestGateTest:
product, api_version, installation_uuid, site_url, home_url, environment, wordpress_version, php_version, plugin_version, environment_signals, and either license_key (activation only) or installation_token (validation and deactivation).
Never transmitted: form definitions, form titles, field labels, field keys, entry data, entry values, submission counts per form, visitor IP addresses, visitor email addresses, customer records, or anything from ad_form_submissions, ad_form_submission_meta or ad_form_payments.
The boundary is structural: nothing in the Licensing namespace accepts a Submission, and the single class that reads a Form is FormEntitlementValidator, which inspects a definition's structure to determine required entitlements and transmits none of it.