Darstellung
Entitlements
The gate. One authority, asked the same way from every layer.
The rule
Features are never checked against a plan.
php
// Forbidden.
if ( 'business' === $plan ) {}
if ( $is_pro ) {}
if ( AD_FORM_PRO ) {}
// The only way.
$entitlements->allows( 'payments.stripe' );The plan exists for badges and upgrade copy. Access comes from the entitlement list in the signed payload plus the Free baseline, and nothing else.
Resolution
EntitlementManager::resolved(), once per request:
Free baseline from the pinned FeatureRegistry
+ signed entitlements, expanded through their dependencies,
but only while LicenseState grants paid entitlementsConsequences worth stating explicitly:
- No license, expired, suspended, revoked, untrusted or offline-expired all land on exactly the Free baseline. A Free license grants precisely what no license grants — asserted in
EntitlementMatrixTest. - Granting
payments.stripealso grantspayments, because the registry declares the dependency. The platform can send leaf keys only. - An entitlement the server grants but this build does not know is honoured. A newer platform must be able to sell a feature before every site has updated.
- The Free baseline is never withheld. A revoked license still leaves the builder, the standard fields, submission handling, entry management and the security controls working.
API
php
$entitlements->allows( string $feature ): bool;
$entitlements->denies( string $feature ): bool;
$entitlements->require( string $feature ): void; // throws NotEntitledException
$entitlements->require_all( array $features ): void;
$entitlements->allows_all( array $features ): array; // returns the missing keys
$entitlements->all(): array;
$entitlements->state( string $feature ): array;
$entitlements->map(): array; // key => bool, for UI
$entitlements->required_plan( string $feature ): LicensePlan;allows_all() returns what is missing rather than a boolean, because compound requirements need to report which half failed. Stripe needs both payments and payments.stripe; holding the parent module alone unlocks nothing.
FeatureGate wraps the same manager and adds the upgrade context that admin screens, the builder and REST error bodies need, so no call site reassembles it.
Why there is no filter
There is no ad_form_entitlements filter, no writable feature registry and no license-state filter. Any of them would be a documented one-line bypass:
php
// This must not be possible.
add_filter( 'ad_form_entitlements', fn() => [ 'payments.stripe' ] );The licensing hooks that do exist are all actions and none can grant anything:
| Hook | Purpose |
|---|---|
ad_form_register_features | Add-ons register their own keys. Core keys cannot be redefined |
ad_form_register_gated_modules | Add-ons declare a module's requirements |
ad_form_feature_denied | Observation only |
ad_form_license_activated / _deactivated / _changed | Notification |
FeatureRegistry::register() refuses any key that is already a core key, so an add-on cannot re-register payments.stripe as a Free feature. Asserted in ModuleGateTest::test_an_addon_cannot_redefine_a_core_module.
The presentational filters (ad_form_license_api_base, ad_form_upgrade_url, ad_form_billing_url) are safe because redirecting the API or the upgrade link grants nothing without the signing key.
Feature registry
Each feature carries key, name, category, dependencies, marketing_plan and a core flag.
marketing_plan is the cheapest plan that advertises the feature. It drives badges and upgrade copy. core: true marks a feature that is never gated at all — see the list at the end of docs/FEATURE_MATRIX.md.
Categories exist so RuntimePolicy can express behaviour per group rather than per feature: forms, fields, layout, integration, styling, validation, notifications, actions, surveys, analytics, payments, pdf, integrations, dynamic, wordpress, submissions, exports, i18n, security, privacy, platform, developer.
Enforcement layers
A feature is gated at every layer that can reach it. UI alone is never enough.
| Layer | Where | Mechanism |
|---|---|---|
| Admin UI | templates/admin/license.php, FeatureStatusTable | Active/Locked overview |
| Builder UI | Palette.tsx, entitlements.ts, LockedFeature.tsx | Badge, lock, upgrade modal |
| Builder config | Admin\Assets::licensing_config() | entitlements map, never the token |
| Save validation | FormService::assert_definition() → FormEntitlementValidator | 403 feature_not_entitled |
| REST | AbstractRestController::deny_feature() | 403 with feature and upgrade_url |
| Module registration | ModuleGate | The integration is never registered |
| Action execution | ActionRunner::run() | Skip and log before the handler runs |
| Runtime policy | RuntimePolicy | Four switches per category |
Field, action and module requirements
Declared once, on the type itself, and read by every layer.
php
// src/Fields/Types/CalculationField.php
public function required_entitlements(): array {
return array( 'forms.calculations' );
}Declared on AbstractField and AbstractAction rather than on FieldInterface and ActionInterface. Adding a method to those interfaces would break any add-on that already implements them; a defaulted method on the abstract cannot. The registries read it through an instanceof AbstractField / AbstractAction check, so an add-on implementing the bare interface keeps working and simply declares no requirements.
AbstractField::to_rest_array() and AbstractAction::to_rest_array() include required_entitlements, and those two payloads feed both GET /fields / GET /actions and the builder config. One edit reaches the API and the UI.
ModuleGate maps a module id to its required keys. Unknown module ids load: a module nobody declared is not licensable, and refusing it would break add-ons that predate the gate. Core module ids cannot be redefined.
The definition scanner
DefinitionFeatureScanner maps a stored form to the feature keys it needs. Pure: no entitlement lookup, no license state, no side effects. That is what lets the save gate, the builder notice and the enforcement audit agree about what a definition contains.
| Definition shape | Required key |
|---|---|
steps with two or more entries | forms.multistep |
field.conditions.enabled with rules | forms.conditional_logic |
action.conditions.enabled with rules | notifications.conditional |
| field type | whatever the type declares |
| action type | whatever the type declares |
more than one email action | notifications.advanced |
non-empty settings.custom_css | styling.custom_css |
A single step is the implicit default, not a paged form. An empty condition group is what the builder leaves behind when the customer switches the feature off, and must not keep the form locked.
Save-time gating is diff-aware
FormEntitlementValidator compares the incoming definition against the stored one and refuses only capabilities that are newly added or changed.
Without this, a customer who drops from Pro+ to Free could not rename a form that was built with Stripe — the save would be refused on config they were not touching, locking them out of their own admin screens. That failure mode is recorded as risk R-02 in docs/EXISTING_ARCHITECTURE_AUDIT.md and covered by FormSaveEnforcementTest.
Duplication passes the source definition as the baseline, so copying a form built on a higher plan keeps working after a downgrade.
Refusals answer 403 with code feature_not_entitled, not 400. The definition is well formed; it is simply not permitted. The builder needs that distinction to tell a permanent refusal from a transient error, which is what stops 1500 ms autosave from becoming an error loop.
Runtime policy
Four independent switches per category, because "locked" is not one behaviour.
| Category | creation | editing | frontend | background |
|---|---|---|---|---|
| forms, fields, layout, validation, surveys, styling, integration, dynamic | no | no | yes | yes |
| notifications, actions, payments, pdf, integrations, wordpress | no | no | no | no |
| anything undeclared | no | no | no | no |
A visitor filling in an existing multi-step form should not see it break because the site owner's card expired: the form keeps working, the owner just cannot build another one. Payments are the opposite — letting a paid gateway keep taking money indefinitely would make the entitlement meaningless.
revoked overrides the table and denies every phase. It still deletes nothing.
Performance
- Entitlements resolve once per request and are memoised.
- The signature verifies at most once per PHP process.
- No licensing option is autoloaded, asserted in
InstallationIdentityTest. - No HTTP request on a frontend page view, ever.
- No licensing option is even read on a frontend page view. The scheduler reconciles its cron event only on admin, cron and WP-CLI requests, so a page view costs nothing at all. Asserted in
FrontendCostTest::test_booting_the_scheduler_on_a_page_view_reads_no_option. Frontend\FormGateis availability, not licensing. The two are deliberately separate classes so a render path never reaches into license state.
The one place licensing is resolved outside the admin is form submission, where ActionRunner consults RuntimePolicy. That is correct: it happens once per submit, not once per view.