Skip to content

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 entitlements

Consequences 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.stripe also grants payments, 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:

HookPurpose
ad_form_register_featuresAdd-ons register their own keys. Core keys cannot be redefined
ad_form_register_gated_modulesAdd-ons declare a module's requirements
ad_form_feature_deniedObservation only
ad_form_license_activated / _deactivated / _changedNotification

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.

LayerWhereMechanism
Admin UItemplates/admin/license.php, FeatureStatusTableActive/Locked overview
Builder UIPalette.tsx, entitlements.ts, LockedFeature.tsxBadge, lock, upgrade modal
Builder configAdmin\Assets::licensing_config()entitlements map, never the token
Save validationFormService::assert_definition()FormEntitlementValidator403 feature_not_entitled
RESTAbstractRestController::deny_feature()403 with feature and upgrade_url
Module registrationModuleGateThe integration is never registered
Action executionActionRunner::run()Skip and log before the handler runs
Runtime policyRuntimePolicyFour 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 shapeRequired key
steps with two or more entriesforms.multistep
field.conditions.enabled with rulesforms.conditional_logic
action.conditions.enabled with rulesnotifications.conditional
field typewhatever the type declares
action typewhatever the type declares
more than one email actionnotifications.advanced
non-empty settings.custom_cssstyling.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.

Categorycreationeditingfrontendbackground
forms, fields, layout, validation, surveys, styling, integration, dynamicnonoyesyes
notifications, actions, payments, pdf, integrations, wordpressnononono
anything undeclarednononono

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\FormGate is 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.

Digitale Lösungen. Persönlich. Zukunftssicher.