Darstellung
Feature enforcement audit
Where each paid feature is actually stopped. Produced after implementation by reading the code, not by restating the plan.
93 features are registered. This document covers every one of them: the 20 that are Free or core have no gate by design and are listed as such, the 73 paid features each name the layers that refuse them.
Legend
| Gate | Meaning |
|---|---|
registry | FieldRegistry / ActionRegistry publish required_entitlements; the builder and GET /fields / GET /actions see the lock |
scan | DefinitionFeatureScanner detects the capability in a stored definition |
save | FormEntitlementValidator inside FormService::assert_definition() → 403 feature_not_entitled |
rest | AbstractRestController::deny_feature() → 403 with feature and upgrade_url |
runtime | ActionRunner::run() refuses execution before the handler is called |
module | ModuleGate prevents registration entirely |
policy | RuntimePolicy decides creation / editing / frontend / background |
ui | Builder palette badge and lock, or the License screen |
admin | Admin screen capability plus entitlement check |
none | Ungated by design |
Every paid feature has at least one server-side gate. ui never appears alone.
Currently enforceable
Features with code behind them today. These are enforced now.
| Feature | Key | Files | Entitlement | UI gate | Backend gate | Runtime gate |
|---|---|---|---|---|---|---|
| Multi-step | forms.multistep | Enforcement/DefinitionFeatureScanner.php:80, Forms/FormService.php:213 | forms.multistep | Inspector lock + badge | scan, save | policy frontend allowed |
| Conditional logic | forms.conditional_logic | DefinitionFeatureScanner.php:118, FormService.php:213 | forms.conditional_logic | Inspector lock + badge | scan, save | policy frontend allowed |
| Calculations | forms.calculations | Fields/Types/CalculationField.php:46, Fields/FieldRegistry.php | forms.calculations | Palette.tsx badge + lock | registry, scan, save | policy frontend allowed |
| Conditional email | notifications.conditional | DefinitionFeatureScanner.php:157 | notifications.conditional | Inspector | scan, save | runtime denied |
| Multiple emails | notifications.advanced | DefinitionFeatureScanner.php:175 | notifications.advanced | Inspector | scan, save | runtime denied |
| Elementor widget | elementor | Elementor/ElementorIntegration.php:52,72 | elementor | Widget absent | module | existing embeds render |
| Gutenberg block | gutenberg | Gutenberg/GutenbergIntegration.php:38 | gutenberg | Block absent | module | existing embeds render |
| Custom CSS | styling.custom_css | DefinitionFeatureScanner.php:190 | styling.custom_css | Inspector | scan, save | policy frontend allowed |
| Duplicate form | forms.duplicate | Forms/FormService.php:196 | Free | — | none | — |
| Form schedule | forms.schedule | Frontend/FormGate.php | Free | — | none | — |
| Email notification | notifications.email | Actions/Types/EmailAction.php | Free | — | none | — |
| Redirect | actions.redirect | Forms/FormDefinition.php settings | Free | — | none | — |
payments.stripe is proven end to end through tests/Support/StripeTestAction.php, which declares the same required_entitlements the real gateway will. The runtime gate refuses it on Free, Pro and on a partial entitlement, and the save gate refuses adding it. The gate is therefore in place before the gateway exists, which is the point.
Unregistered premium types are pinned on the registries themselves (FieldRegistry::RESERVED_TYPES, ActionRegistry::RESERVED_TYPES). A crafted JSON body naming repeater or stripe before those classes exist is refused at save time on Free/Pro, and a Pro+ site may store the reserved Stripe action so it fires the day the gateway ships.
Reserved but not yet implemented
These have a registered key, a documented gate and a plan assignment. There is no code to gate yet. Recorded so the feature is born gated instead of retrofitted — the failure mode this whole document exists to prevent.
The gate column states where the check must go when the feature is built. A feature added without it will show up here as a gap.
Fields — registry, scan, save
fields.advanced, fields.address, fields.file_upload, fields.signature, fields.gdpr, fields.product, fields.order
Declared via required_entitlements() on the field class. The registry, the scanner, the save gate, the palette and the REST catalogue then pick it up with no further wiring, as CalculationField already demonstrates.
Form builder — scan, save, rest
forms.templates, forms.import_export, forms.restrictions, forms.repeater, forms.conversational
Layout — scan, save
layout.containers, layout.field_groups, layout.section_backgrounds
layout.columns, layout.sections and layout.responsive are Free. Note that definition.layout.rows is currently a persisted placeholder with no reader, so these three are Free features the plugin does not yet render.
Styling — rest, save, admin
styling.global
Page builders — module
elementor.full_style_panel
Actions — registry, scan, save, runtime
actions.webhook, actions.wp_user, actions.wp_post
Declared via required_entitlements() on the action class. ActionRunner then refuses execution automatically.
Payments — module, registry, scan, save, runtime
payments, payments.stripe, payments.paypal, payments.mollie, payments.subscriptions, payments.donations, payments.products, payments.discounts, payments.taxes, payments.shipping, payments.live_calculation
RuntimePolicy denies the payments category in every phase, including the frontend. A gateway must also never write plugin-license data into ad_form_payments.
PDF — module, scan, save, runtime
pdf, pdf.invoice, pdf.quote, pdf.contract, pdf.email
Integrations — module, runtime
integrations, integrations.mailchimp, integrations.google_sheets, integrations.slack, integrations.zapier, integrations.hubspot, integrations.salesforce, integrations.activecampaign
Each has its own key so the dashboard can move individual integrations between plans without a plugin release.
Surveys and quiz — scan, save, rest
surveys, quiz
Analytics — rest, admin
analytics.basic, analytics.advanced
analytics.basic is partial: the analytics_enabled setting and Database\Stats exist, but there is no analytics feature behind them.
Dynamic data and WordPress — scan, save, runtime
dynamic.prefill, wordpress.frontend_post, wordpress.user_management
Export and i18n — rest, module
exports.advanced, i18n.advanced
Security — scan, save, runtime, admin
security.recaptcha, security.hcaptcha, security.turnstile, security.rate_limiting, security.akismet, security.advanced_bot_detection
Privacy — rest, admin
privacy.gdpr
Platform and developer — admin, rest, module
white_label, team.roles, team.permissions, logging.full, developer.api, developer.addons
logging.full is partial: Logger exists as the Free logging.basic baseline; the Business tier above it does not.
Ungated by design
These 20 keys carry core: true or sit on Free and must never acquire an entitlement lookup. A revoked license leaves all of them working.
| Key | Reason |
|---|---|
forms.builder | Building a form is the product, not an upsell |
forms.autosave | Data-loss prevention |
forms.undo_redo | Data-loss prevention |
forms.shortcode | The only universal embed method |
forms.duplicate | See FEATURE_MATRIX_REVIEW.md |
forms.schedule | Already shipped as Free behaviour |
fields.standard | A form builder without fields is not one |
validation.basic | Security control |
validation.live | Free per the product matrix |
layout.columns, layout.sections, layout.responsive | Free per the product matrix |
notifications.email | A contact form that cannot notify does not contact |
actions.redirect | Already shipped as Free confirmation behaviour |
gutenberg | The only block-editor embed path for a Free user |
submissions.management | Reading customer data must never be gated |
security.honeypot | Security control |
security.nonce | Security control |
privacy.storage | Compliance control |
logging.basic | Diagnosability |
Also ungated and not registry features at all: form submission and entry storage, server-side condition evaluation during submit, nonce verification, sanitisation, escaping, database migrations, and the License screen itself — the last so a revoked license can still be repaired.
Cross-cutting enforcement
| Concern | Where |
|---|---|
| Entitlement resolution | Entitlements/EntitlementManager.php — the only authority |
| Free baseline | Entitlements/FeatureRegistry::free_keys() |
| Dependency expansion | FeatureRegistry::with_dependencies() |
| Runtime phases | Entitlements/RuntimePolicy.php |
| Refusal exception | Entitlements/NotEntitledException.php, 403 feature_not_entitled |
| REST envelope | REST/AbstractRestController::deny_feature() |
| Save choke point | Forms/FormService::assert_definition() |
| Action execution | Actions/ActionRunner::may_run() |
| Module registration | Enforcement/ModuleGate.php |
| Locked-form marking | Enforcement/FormEntitlementValidator::locked_usages() |
| Builder data | Admin/Assets::licensing_config() |
| Builder UI | assets/src/builder/entitlements.ts, components/LockedFeature.tsx, components/Palette.tsx |
Gaps and honest notes
REST catalogue endpoints are not filtered.
GET /fieldsandGET /actionsreturn every type with itsrequired_entitlements, and the client renders the lock. Nothing is leaked — the type catalogue is marketing information, and the save gate refuses the type regardless. Filtering the catalogue would only hide what a customer could buy.FormDefinitionValidatorstill does not check types against the registries. Pre-existing behaviour, recorded in the Phase 0 audit. An invented field type is inert rather than free: it declares no entitlements, so the scanner finds nothing, and at submit time it falls back tosanitize_text_field. Worth closing on its own merits, not as a licensing fix.The
layout.*Free features have no renderer.definition.layout.rowsis persisted and never read. The keys are registered and Free, so nothing is wrongly locked, but the matrix currently advertises three Free features the plugin does not render.notifications.emailis a boolean, not a count. The scanner treats a second email action asnotifications.advanced, which implements the intended rule, butEntitlementManagerhas no quantitative entitlement concept. If the product wants "Free gets exactly N of X", that needs a contract change. Open inFEATURE_MATRIX_REVIEW.md.fields.standardis uncapped. Eight field types exist against a matrix that promises 25. The key is a boolean, so shipping more standard fields needs no licensing change — but the count gap is real and is a scope item, not a licensing one.No network-admin licence screen on multisite. Each blog manages its own licence. Additive later; not a gap in enforcement.
Verification
composer install
./vendor/bin/phpunit # 525 tests, 1238 assertions
./vendor/bin/phpcs # clean
npm run build # cleanThe enforcement claims above are backed by tests/Unit/Licensing/EntitlementMatrixTest.php (plan matrix and all five downgrade paths), FormSaveEnforcementTest.php (save gate, including diff-awareness and duplication), RuntimeEnforcementTest.php (action execution and the runtime policy), ModuleGateTest.php (module registration), RestGateTest.php (refusal envelope and the privacy boundary), and BypassAttemptTest.php (thirteen bypass attempts, all failing).