Darstellung
Existing architecture audit (Phase 0)
Read-only audit of the AD Form codebase before any licensing code is written. No production file was changed to produce this document.
| Key | Value |
|---|---|
| Audited tree | cursor/phase-11-multi-step-9811 (596f1c2) |
| Plugin version | 0.11.0 (AD_FORM_VERSION, PluginInfo::VERSION) |
| DB schema version | 1 (PluginInfo::DB_VERSION) |
| Namespace | Dingfelder\AdForm (PSR-4 → src/) |
| Text domain / slug | ad-form |
| REST namespace | ad-form/v1 |
| Minimum PHP / WP | 8.2 / 6.4 |
| Baseline test suite | 163 tests, 636 assertions, green |
0. Branch state
The audit target is not main. main contains only the Phase 1 foundation (2969ad2). Phases 2–11 exist as a stacked chain of branches where every PR targets the previous phase branch instead of main, so the newest complete tree is the tip of cursor/phase-11-multi-step-9811.
main (2969ad2) ── Phase 1 only
└── phase-2-form-model … phase-11-multi-step (596f1c2) ── everything elseLicensing must be built against the phase-11 tree, otherwise it would be written against a code base that lacks fields, actions, submissions, REST controllers, the builder, Elementor and Gutenberg. See risk R-01.
1. Bootstrap and lifecycle
ad-form.php is a thin loader. It defines five constants (AD_FORM_VERSION, AD_FORM_FILE, AD_FORM_DIR, AD_FORM_URL, AD_FORM_BASENAME), hard-stops on PHP < 8.2 with an admin notice, registers the autoloader, registers the activation/deactivation hooks, and boots Plugin::instance()->boot() on plugins_loaded at priority 5.
src/Autoloader.php prefers vendor/autoload.php when present and otherwise falls back to its own PSR-4 mapper, so the plugin ships without a Composer runtime dependency. Any licensing code must stay inside Dingfelder\AdForm\ and must not require a Composer package at runtime.
Boot order (src/Plugin.php)
load_textdomain()register_providers()— fixed list, then filtered throughad_form_service_providersregister()on every providerdo_action( 'ad_form_register_services', $container )boot()on every providerdo_action( 'ad_form_loaded', $plugin )
Provider order is significant because boot() runs in list order:
php
$providers = array(
new CoreServiceProvider(),
new DatabaseServiceProvider(),
new FormsServiceProvider(),
new FieldsServiceProvider(),
new ConditionsServiceProvider(),
new CalculationsServiceProvider(),
new ActionsServiceProvider(),
new AdminServiceProvider(),
new RestServiceProvider(),
new FrontendServiceProvider(),
new ElementorServiceProvider(),
new GutenbergServiceProvider(),
);A LicensingServiceProvider has to be inserted after DatabaseServiceProvider (it needs DatabaseInterface, OptionsInterface and LoggerInterface from CoreServiceProvider) and before FieldsServiceProvider, so that entitlements are resolvable while field, action, REST, frontend and integration providers boot.
Container (src/Core/Container.php)
Small hand-written container, no Symfony, no autowiring.
| Method | Signature |
|---|---|
singleton | singleton( string $id, callable $factory ): void — shared |
bind | bind( string $id, callable $factory ): void — transient |
instance | instance( string $id, mixed $instance ): void |
has | has( string $id ): bool |
get | get( string $id ): mixed, throws ContainerException on missing id or circular resolution |
Factories receive the container: callable(Container): mixed. Services are keyed by class or interface name. Constructor injection is done manually inside the factories, as in CoreServiceProvider::register().
ModuleInterface is dead code
src/Core/ModuleInterface.php declares id(), register(), boot(), and Hooks::REGISTER_MODULES (ad_form_register_modules) exists, but nothing in the tree implements the interface and the hook is never fired. There is therefore no module registry today. The "Module Registry" requirement (id, required_entitlements, dependencies) has to be built, not retrofitted, and ModuleInterface is the natural place to extend because no consumer can break.
Activation, deactivation, uninstall
| Class | Behaviour |
|---|---|
Core\Activator | activate( bool $network_wide ); on network-wide activation iterates get_sites() and runs activate_site() per blog. activate_site() = migrate schema, grant caps to administrator, seed options, flush rewrite rules. on_new_site() is hooked to wp_initialize_site in DatabaseServiceProvider:78. |
Core\Deactivator | flush_rewrite_rules() only. No data touched, $network_wide deliberately ignored. |
Core\Uninstaller | Per-site loop on multisite. Drops the 7 tables, deletes ad_form_settings / ad_form_db_version / ad_form_version, revokes capabilities — only when Settings::delete_data_on_uninstall is true. Default is false. |
This is already the behaviour licensing needs: plugin deactivation is not a destructive event, and uninstall is opt-in. Deactivation must therefore not call the license deactivate endpoint (see docs/LICENSING_INTEGRATION_PLAN.md).
2. Options and multisite reality
Core\OptionsInterface has exactly three methods:
php
public function get( string $key, mixed $default_value = false ): mixed;
public function update( string $key, mixed $value, bool $autoload = true ): bool;
public function delete( string $key ): bool;Core\WpOptions is a thin wrapper over get_option / update_option / delete_option. There is no get_site_option / update_site_option usage anywhere in src/. Combined with the per-blog schema installation in Activator and the per-blog purge in Uninstaller, the plugin is already consistently per-site scoped, including on multisite. Nothing is shared across a network today.
Persisted options:
| Option | Constant | Content | Autoload |
|---|---|---|---|
ad_form_settings | OPTION_SETTINGS | delete_data_on_uninstall, store_ip, store_user_agent, debug_logging, analytics_enabled | yes |
ad_form_db_version | OPTION_DB_VERSION | int | yes |
ad_form_version | OPTION_VERSION | version string | yes |
Settings::sanitize() is a strict allow-list and is filtered through ad_form_settings. License state must not live in ad_form_settings: that option is autoloaded, user-writable through the settings screen, and its sanitizer would silently drop unknown keys. Licensing needs its own options.
3. What licensing has to build from scratch
Greps across src/ returned no matches for any of these:
| Capability | Grep result | Consequence |
|---|---|---|
| Outbound HTTP | no wp_remote_* anywhere | LicenseApiClient is the plugin's first HTTP client |
| Scheduling | no wp_schedule_event / wp_next_scheduled | LicenseScheduler is the first cron consumer |
| Caching | no set_transient / get_transient | no cache conventions to follow; use own option + LicenseCache |
| Crypto | no sodium_*, no openssl_* | SignatureVerifier is greenfield |
| Network options | no *_site_option | per-site scope is the established convention |
Ed25519 was verified as available on the target runtime: extension_loaded('sodium') and function_exists('sodium_crypto_sign_verify_detached') are both true on stock PHP 8.3. libsodium has been bundled in core PHP since 7.2, so on the plugin's PHP 8.2 floor sodium_crypto_sign_verify_detached() can be treated as present, with a guarded hard failure rather than a silent fallback to a weaker algorithm.
4. Database
Seven tables, all prefixed {wp_prefix}ad_form_, all created by a single dbDelta pass in Migrator::migration_1_initial_schema().
| Table | Purpose | Licensing relevance |
|---|---|---|
ad_form_forms | Form entity, definition longtext, version, submission_count, starts_at/ends_at | Save-time gating target; definition carries all premium config |
ad_form_form_meta | Key/value per form | Candidate store for contains_locked_features |
ad_form_submissions | Entry, payload snapshot, payment_status column already present | Runtime gating target |
ad_form_submission_meta | Queryable field values (value_text, value_num, value_long) | Never sent to the license server |
ad_form_logs | channel, level, message, context | Licensing log sink |
ad_form_payments | gateway, gateway_payment_id, amount, currency, status, mode | Form payments (System B) only. Must never hold plugin-license data |
ad_form_migrations | Applied-migration audit trail | — |
ad_form_payments already exists but no gateway code does. That table is the customer's own money flowing through the customer's own Stripe/PayPal/Mollie account. Plugin-licence billing (System A) must never write here and must never reuse gateway_payment_id or any Stripe identifier.
Adding a migration means: bump PluginInfo::DB_VERSION, add n => array( $this, 'migration_n_…' ) in Migrator::migrations(), extend Schema::create_statements() / TableNames / expected_indexes() / TableNames::all(). Licensing does not need a table — option-backed storage is sufficient and keeps DB_VERSION at 1.
5. Forms: model, save path, definition schema
Definition shape
FormDefinition::defaults() defines eight top-level keys: schema_version (currently 1), settings, fields, layout, steps, actions, conditions, styles.
| Key | Content | Notes |
|---|---|---|
settings | ajax, honeypot, require_login, confirmation_type, confirmation_message, redirect_url | confirmation_type: redirect is the only redirect mechanism today |
fields[] | id, type, key, label, required, step, settings, conditions | Calculation formula lives in settings.formula |
steps[] | id, title, description, next_label, prev_label | Fields join via field.step |
actions[] | id, type, enabled, settings, conditions | Only email is registered |
layout | { rows: [] } | Persisted placeholder, never rendered. Only sanitised in FormDefinition:72, no reader anywhere |
conditions | top-level list | Persisted placeholder, never evaluated. ConditionEngine, ActionRunner and SubmitService only read per-field and per-action conditions |
styles | free-form object | No consumer |
Both the JSON schema (FormJsonSchema, additionalProperties: true) and the sanitiser preserve unknown keys:
php
foreach ( $item as $extra_key => $extra_value ) {
if ( is_string( $extra_key ) && ! array_key_exists( $extra_key, $field ) ) {
$field[ $extra_key ] = $extra_value;
}
}This cuts both ways. It is exactly what the no-data-loss downgrade policy needs — a Pro+ Stripe action survives a drop to Free untouched — and it is also why save-time gating cannot rely on the sanitiser stripping anything. The FormEntitlementValidator has to reject or quarantine explicitly.
Save path
FormsController::create / update
→ FormService::create( array $input ) / update( int $id, array $input )
→ FormDefinition::sanitize( mixed $raw ) # + filter ad_form_form_definition
→ FormService::assert_definition( array ) # ← single choke point
→ FormDefinitionValidator::validate(): array<string,string>
→ do_action( ad_form_before_form_save )
→ FormRepository::insert / update
→ do_action( ad_form_after_form_save )assert_definition() is private and is the only place both create() and update() funnel through:
php
private function assert_definition( array $definition ): void {
$errors = $this->validator->validate( $definition );
if ( array() !== $errors ) {
throw FormException::invalid_definition( $errors );
}
}FormService is constructed with exactly two collaborators (FormRepositoryInterface, FormDefinitionValidator) in FormsServiceProvider, so a third readonly constructor argument is the least invasive way to add entitlement validation.
Two gaps worth recording: FormDefinitionValidator checks that a field/action type is non-empty but never checks it against FieldRegistry::has() or ActionRegistry::has(); and FormService::duplicate() re-enters create() with the source definition, so duplication is automatically covered by whatever gate assert_definition() gains.
6. Fields
FieldRegistry::boot() registers 8 core types, then fires ad_form_register_fields (action) and ad_form_fields (filter).
| type | class | Notes |
|---|---|---|
text | TextField | |
email | EmailField | |
number | NumberField | |
textarea | TextareaField | |
select | SelectField | |
checkbox | CheckboxField | |
calculation | CalculationField | reads settings.formula |
submit | SubmitField |
AbstractField declares five category constants — basic, advanced, business, wordpress, layout — but every shipped field uses basic. The category vocabulary for premium tiers already exists and is unused.
FieldInterface has 14 methods and no entitlement concept. Adding a method to the interface would break any add-on already implementing it, so the non-breaking route is a required_entitlements(): array default on AbstractField plus one extra key in AbstractField::to_rest_array(), which is the single payload consumed by both GET /fields and the builder config.
The claimed "25 standard fields" for the Free tier do not exist. 8 exist.
7. Actions
ActionRegistry holds exactly one type: email (EmailAction). Registration is asymmetric to fields — the instance is constructed directly inside ActionsServiceProvider::register() and boot() only applies the ad_form_actions filter; there is no ad_form_register_actions action hook.
Execution is decoupled from the submit service. ActionsServiceProvider::boot() subscribes ActionRunner::run() to ad_form_after_submission, so actions run after the entry is already persisted. ActionRunner::run() loops the definition's actions and, per action, checks enabled, evaluates action conditions, resolves the handler, then fires ad_form_before_action and calls $handler->run():
php
$handler = $this->registry->get( $type );
if ( ! $handler instanceof ActionInterface ) {
$this->logger->warning(
'actions',
'Unknown submit action skipped.',
…
);
continue;
}
$settings = is_array( $action['settings'] ?? null ) ? $action['settings'] : array();
do_action( Hooks::BEFORE_ACTION, $type, $settings, $context );
try {
$result = $handler->run( $settings, $context );Line 84 (after handler resolution, before BEFORE_ACTION) is the runtime gate for every current and future action, including payments. Unknown types are already logged and skipped, so a denied action can reuse the same skip-and-log shape without changing the runner's contract.
Because actions run on ad_form_after_submission, a denied payment action does not prevent the entry from being stored. That is the desired shape for the downgrade policy — the visitor's data is kept, the paid execution is not performed.
8. Submit pipeline
Two entry points converge on one service:
- REST:
SubmitController::submit()→FormService::get()→SubmitService::submit() - Classic POST:
SubmitListener::maybe_handle()ontemplate_redirectpriority 5 → same
SubmitService::submit( Form $form, array $input, RequestContext $context ): SubmitResult runs: FormGate::can_submit() → per-form nonce (Nonce::submit_action( $form_id )) → honeypot → ad_form_before_validation → sanitise via FieldRegistry → a four-pass loop of CalculationEngine::apply() + ConditionEngine::visible_map() → validate visible fields → ad_form_after_validation → ad_form_before_submission → persist → ad_form_after_submission.
Two facts matter for gating:
- Hidden fields are dropped from the clean payload and never validated, so a frontend that fakes visibility cannot smuggle values past validation, but it also means condition evaluation is authoritative server-side already.
- There is no server-side step validation. Multi-step is purely a frontend concern (
assets/frontend/js/form.js); the server always receives the full field set. Multi-step therefore has no runtime surface to gate — only save-time and UI.
Unknown field types fall back to sanitize_text_field rather than being rejected (SubmitService:88-90).
9. Frontend
Frontend\FormGate is not a licensing gate despite the name. It answers availability only: status must be published, starts_at/ends_at window, require_login. Reason constants are not_found, not_published, not_open, closed, login_required. Users holding edit_ad_form_forms or manage_ad_form_forms bypass the publish check for preview. Licensing must not be folded into this class; a separate FeatureGate keeps the two concerns from entangling.
FormRenderer::render_form() fires ad_form_before_form_render, checks FormGate::can_render(), applies calculations, pages via StepEngine::pages(), computes ConditionEngine::visible_map(), renders fields through the registry, and filters the result through ad_form_rendered_html. Field conditions are emitted as a data-conditions JSON attribute for client-side re-evaluation.
Frontend assets are enqueued only after a form is marked present, and window.adFormFrontend carries i18n only. There is no per-request state that a license check could piggyback on, which is good: the license cache must be read from an option, never fetched, during rendering.
10. REST API
21 routes under ad-form/v1. Every route already has a permission_callback from REST\Permissions.
| Method | Route | Permission |
|---|---|---|
| GET | /status | public_status (always true) |
| GET | /system | manage_settings |
| GET | /forms/schema | edit_forms |
| GET | /forms/picker | pick_forms |
| GET | /forms | edit_forms |
| POST | /forms | create_forms |
| POST | /forms/(?P<id>\d+)/duplicate | create_forms |
| GET | /forms/(?P<id>\d+) | edit_forms |
| PUT, PATCH | /forms/(?P<id>\d+) | edit_forms |
| DELETE | /forms/(?P<id>\d+) | delete_forms |
| GET | /fields | edit_forms |
| GET | /actions | edit_forms |
| GET | /conditions | edit_forms |
| GET | /calculations | edit_forms |
| POST | /forms/(?P<id>\d+)/submit | submit_form (always true, nonce in handler) |
| GET | /submissions | view_submissions |
| POST | /submissions/bulk | edit_submissions |
| GET | /submissions/(?P<id>\d+) | view_submissions |
| PUT, PATCH | /submissions/(?P<id>\d+) | edit_submissions |
| DELETE | /submissions/(?P<id>\d+) | delete_submissions |
AbstractRestController offers only ok() and error(). error() already produces the exact response envelope the licensing spec asks for:
php
protected function error( string $code, string $message, int $status = 400, array $errors = array() ): WP_Error {
$data = array(
'success' => false,
'code' => $code,
'message' => $message,
);
if ( array() !== $errors ) {
$data['errors'] = $errors;
}
return new WP_Error( $code, $message, array( 'status' => $status, 'data' => $data ) );
}A feature_not_entitled / 403 response therefore needs no new envelope, only a deny_feature() helper on the same base class plus a feature key.
RestException (src/Exception/RestException.php) and Permissions::admin_rest() are both defined and never used. RestException is the obvious carrier for entitlement denials thrown from deeper layers, since controllers already translate PluginException implementations (error_code(), status(), errors()) into error() calls.
do_action( 'ad_form_rest_routes', $container ) fires after core routes, which is where a licence controller would register.
11. Admin
Admin\Menu::register() builds one top-level page (ad-form, cap manage_ad_form_forms, position 58, dashicons-feedback) and four submenus: Dashboard (ad-form), Forms (ad-form-forms, edit_ad_form_forms), Entries (ad-form-entries, view_ad_form_submissions), Settings (ad-form-settings, manage_ad_form_settings).
A License submenu slots in after Settings with cap manage_ad_form_settings. Three coordinated edits are required: Menu::register() + a render_license() callback, the View::TEMPLATES allow-list (currently dashboard, forms, form-edit, entries, entry-detail, settings), and a new templates/admin/license.php. View::render() throws RuntimeException for templates outside the allow-list, so forgetting the second edit fails loudly.
Assets load whenever the hook suffix or screen id contains ad-form, so a ad-form-license page inherits admin.css / admin.js and the adFormAdmin localisation automatically.
Builder config injection
Admin\Assets::enqueue_builder() is where the builder learns about the world. It writes window.adFormBuilder through wp_add_inline_script (not wp_localize_script, deliberately, to keep booleans intact) with keys restUrl, restNonce, formId, form, fieldTypes, actionTypes, mergeTags, conditionOperators, categories, statuses, listUrl, plugin, version, i18n.
fieldTypes comes from FieldRegistry::to_rest_array() and actionTypes from ActionRegistry::to_rest_array() — the same two payloads served by GET /fields and GET /actions. Enriching those two methods with entitlement metadata updates the REST catalogue and the builder in one move. An entitlements block added to this config is what the locked-feature UI needs; the installation token must never appear here.
12. Builder frontend
React through @wordpress/element, TypeScript, webpack via @wordpress/scripts. Entry assets/src/builder/index.tsx mounts into #ad-form-builder only when window.adFormBuilder exists. No Redux — all state is useState in App.tsx (title, status, definition, version, shortcode, selectedId, saveStatus), with HistoryStack in history.ts for undo/redo (400 ms debounce) and autosave at a 1500 ms debounce.
api.ts sends X-WP-Nonce: config.restNonce with credentials: 'same-origin' and saves via PATCH forms/{formId}, handling HTTP 409 / version_conflict.
Palette.tsx renders one <button> per field type inside category groups — that button is where a plan badge and a lock click-handler belong. types.ts needs FieldType and ActionType extended with the entitlement metadata, and BuilderConfig extended with the resolved entitlement map. FormDefinition in types.ts already carries [key: string]: unknown, so server-side markers need no TypeScript change.
None of this is a security boundary. Every gate expressed here must have a server-side counterpart in assert_definition(), the REST layer or ActionRunner.
13. Optional integrations
ElementorServiceProvider and GutenbergServiceProvider both register and boot unconditionally; the host check lives inside the integration classes:
ElementorIntegration::boot()hookselementor/loaded;is_available()=did_action('elementor/loaded') > 0 || class_exists('\Elementor\Plugin'); the widget registers only ifclass_exists('\Elementor\Widget_Base').GutenbergIntegration::register()returns early unlessfunction_exists('register_block_type'); the block also needs a readableassets/build/block.asset.php.ad_form_gutenberg_registerfires regardless.
Both integrations are gate-friendly: the entitlement check goes next to the existing host check, so an unentitled site simply never registers the widget or block.
14. Declared but unimplemented
Interfaces exist with zero implementations. These are seams, not features, and the feature matrix must not present them as shipped.
| Interface | Status |
|---|---|
Payments\PaymentGatewayInterface | id(), label(), create_payment( array $order ): array. No implementation. Table exists |
Integrations\IntegrationInterface | id(), label(), is_configured(). No implementation. ad_form_integrations never fired |
Core\ModuleInterface | No implementation. ad_form_register_modules never fired |
Support\ValidationRuleInterface | ad_form_validation_rules never fired |
Templates\FormRendererInterface | Implemented by Frontend\FormRenderer |
Notifications\MailerInterface | Implemented by Notifications\WpMailer |
Also dead: definition.layout.rows and top-level definition.conditions (both persisted, no reader), RestException, Permissions::admin_rest(), Nonce::ADMIN, Nonce::SETTINGS, and analytics_enabled (a settings flag with no analytics code behind it — Database\Stats only counts forms and entries).
15. Feature inventory
What actually exists, as opposed to what the target feature matrix assumes.
Implemented
| Area | Detail |
|---|---|
| Form builder | React drag & drop (dnd.ts), autosave, undo/redo, field duplicate, optimistic-concurrency save via version |
| Form CRUD | Create, read, update, delete, duplicate, slug uniqueness, 4 statuses (draft, published, disabled, archived), schedule window |
| Fields | 8 types: text, email, number, textarea, select, checkbox, calculation, submit |
| Validation | Required + per-type format validation server-side; browser-native + JS client-side |
| Conditional logic | ConditionEngine, 10 operators, show/hide, all/any; per field and per action; server-authoritative |
| Calculations | CalculationEngine with a safe parser, min/max/abs/round, 4-pass resolution, calculation field |
| Multi-step | StepEngine, progress display, Back/Next, single submit. Frontend-only paging |
| Notifications | EmailAction with merge tags (MergeTagEngine), HTML/plain modes, per-action conditional sending, WpMailer |
| Confirmation | Inline message or redirect (settings.confirmation_type) |
| Submissions | Persist + payload snapshot + queryable meta, entries list, detail view, read/unread, star, spam, trash, restore, permanent delete, bulk operations |
| Frontend | Shortcode, AJAX and classic POST submit, conditional asset enqueue, FormGate availability rules |
| Gutenberg | Block ad-form/form with published-form picker |
| Elementor | Widget ad-form with published-form picker |
| Security | Per-form submit nonce, honeypot, capability model (8 caps), allow-list sanitiser, log redaction, IP hashing/anonymisation options |
| Logging | Logger to ad_form_logs, 4 levels, channel-based, debug gated by settings |
| Privacy controls | store_ip (none/hashed/anonymized/raw), store_user_agent, opt-in data deletion on uninstall |
| Infrastructure | Container, providers, 39 hooks, versioned migrations, multisite-aware activation, i18n .pot |
Absent — every one of these is in the target matrix
Templates, import/export, repeater, conversational forms, quiz/scoring, surveys, address/upload/signature/GDPR/product/order fields, layout columns and sections (placeholder only), section backgrounds, container and field groups, global styles, custom CSS, live validation config, advanced/conditional notification tiers beyond what EmailAction covers, webhook action, WP user action, WP post action, frontend post submission, dynamic prefill, all analytics, every payment gateway, subscriptions, donations, discounts, tax, shipping, live price calculation, all PDF generation, all 7 named integrations, advanced exports, WPML/Polylang/RTL handling, reCAPTCHA/hCaptcha/Turnstile, rate limiting, Akismet, advanced bot detection, GDPR tooling beyond IP options, form restrictions, white label, team roles and permissions, full logging tier, developer API surface, add-on framework.
The registry in docs/FEATURE_MATRIX.md covers both sets. Roughly one in five listed keys has code behind it today; the rest are placeholders whose gate is recorded now so that the feature is born gated rather than retrofitted.
16. Licensing integration points
Ordered by enforcement layer. Full file-level plan in docs/LICENSING_INTEGRATION_PLAN.md.
| # | Layer | Anchor | Change |
|---|---|---|---|
| I-01 | Bootstrap | Plugin::register_providers() | Insert LicensingServiceProvider after DatabaseServiceProvider |
| I-02 | Identity | Core\Activator::activate_site() | Seed installation_uuid if absent; never regenerate |
| I-03 | Scheduler | LicensingServiceProvider::boot() | wp_next_scheduled + jittered daily event; first cron use in the plugin |
| I-04 | Save gate | FormService::assert_definition() | Third constructor arg FormEntitlementValidator, run after FormDefinitionValidator |
| I-05 | Runtime gate | ActionRunner::run() line 84 | Deny by entitlement before ad_form_before_action, reuse skip-and-log |
| I-06 | REST gate | AbstractRestController + per-controller | deny_feature() returning 403 feature_not_entitled |
| I-07 | Catalogue | FieldRegistry::to_rest_array(), ActionRegistry::to_rest_array() | Add required_entitlements + locked; feeds GET /fields, GET /actions and the builder in one place |
| I-08 | Field metadata | AbstractField::required_entitlements() | New defaulted method on the abstract, not on FieldInterface |
| I-09 | Action metadata | AbstractAction::required_entitlements() | Same shape |
| I-10 | Module registry | Core\ModuleInterface + new ModuleRegistry | Dead interface, safe to extend with required_entitlements() |
| I-11 | Elementor | ElementorIntegration::register() | Entitlement check beside the elementor/loaded host check |
| I-12 | Gutenberg | GutenbergIntegration::register() | Entitlement check beside the register_block_type host check |
| I-13 | Admin menu | Menu::register(), View::TEMPLATES, new template | License page |
| I-14 | Builder config | Admin\Assets::enqueue_builder() | entitlements + upgradeUrl in window.adFormBuilder; never the token |
| I-15 | Builder UI | Palette.tsx, Inspector.tsx, types.ts | Plan badges, lock affordance, LockedFeature component |
| I-16 | Downgrade marker | ad_form_form_meta | contains_locked_features + admin notice on the forms list |
| I-17 | Logging | Logging\Logger | license channel; extend redaction with license_key, installation_token |
| I-18 | Uninstall | Core\Uninstaller | Include licensing options in the opt-in purge |
Deliberately not touched: Frontend\FormGate (availability, not entitlement), Deactivator (must not release the installation), the ad_form_payments table (System B only), Settings/ad_form_settings (wrong lifecycle and sanitiser for license state).
17. Risks
| # | Risk | Impact | Mitigation |
|---|---|---|---|
| R-01 | Branch divergence: main has Phase 1 only, real code is the phase-11 tip | Licensing built on main would target a skeleton | Build on the phase-11 tree; land phases 2–11 into main before or with Phase 1 of licensing |
| R-02 | FormService::assert_definition() also runs for update() on forms saved under a higher plan | A Free user could be unable to save an unrelated title change on an old Pro+ form | Diff-based enforcement: reject only newly added or modified premium config, allow pass-through of pre-existing config. Explicit test required |
| R-03 | duplicate() re-enters create() | Duplicating a Pro+ form on Free would hard-fail | Decide deliberately: block duplication with a clear message, or copy and mark contains_locked_features |
| R-04 | Autosave every 1500 ms hits PATCH /forms/{id} | A rejecting save gate turns into an autosave error loop in the builder | Builder must surface feature_not_entitled distinctly and stop retrying; server response must be idempotent |
| R-05 | Unknown extra keys are preserved through sanitisation | Cannot rely on sanitisation to strip premium config | Explicit detection in FormEntitlementValidator; treat preservation as a feature for downgrades |
| R-06 | Actions run on ad_form_after_submission, after persistence | A denied payment action still leaves a stored entry | Intended. Document it: entry retained, paid execution refused, payment_status left untouched |
| R-07 | No server-side step validation | Multi-step has no runtime surface | Gate multi-step at save time and in UI only; do not invent a runtime check |
| R-08 | 163 tests rely on hand-written WP stubs in tests/bootstrap.php | wp_remote_post, wp_schedule_event, sodium_*, wp_next_scheduled are unavailable to tests | Extend the stub layer and inject an HTTP transport interface so LicenseApiClient is testable without network |
| R-09 | License state in an autoloaded option | Every request pays the deserialisation cost | Keep the payload compact, autoload only the hot entitlement slice, keep the signed blob in a non-autoloaded option |
| R-10 | Per-site options on multisite | A 200-site network would activate 200 installations | Decide the multisite policy up front (see the plan); send network context as a signal and let the API decide |
| R-11 | Site URL changes (http→https, www, migration, staging clone) | Naive string comparison would deactivate working installations | Never compare URLs for equality client-side; report the URL and let the API decide |
| R-12 | Ed25519 unavailable on an exotic host | Signature verification impossible | Guard on function_exists( 'sodium_crypto_sign_verify_detached' ), fail closed to Free with an admin notice, never fall back to a weaker algorithm |
| R-13 | ad_form_payments and Stripe naming collision between System A and System B | Customer payment data mixed with license billing | Licensing writes no payment table, stores no Stripe identifier, and uses only plan / license_status / entitlements |
| R-14 | Entitlement lookups inside render and submit loops | Frontend regression | Resolve entitlements once per request from cache; forbid HTTP in the render path |
| R-15 | Categories advanced / business / wordpress exist unused on AbstractField | Category and plan could be conflated | Categories stay presentational; enforcement reads entitlements only |
18. Verified baseline
composer install
./vendor/bin/phpunit # OK (163 tests, 636 assertions)PHP 8.3.6, sodium present. Any licensing work must keep this suite green.