Skip to content

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.

KeyValue
Audited treecursor/phase-11-multi-step-9811 (596f1c2)
Plugin version0.11.0 (AD_FORM_VERSION, PluginInfo::VERSION)
DB schema version1 (PluginInfo::DB_VERSION)
NamespaceDingfelder\AdForm (PSR-4 → src/)
Text domain / slugad-form
REST namespacead-form/v1
Minimum PHP / WP8.2 / 6.4
Baseline test suite163 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 else

Licensing 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)

  1. load_textdomain()
  2. register_providers() — fixed list, then filtered through ad_form_service_providers
  3. register() on every provider
  4. do_action( 'ad_form_register_services', $container )
  5. boot() on every provider
  6. do_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.

MethodSignature
singletonsingleton( string $id, callable $factory ): void — shared
bindbind( string $id, callable $factory ): void — transient
instanceinstance( string $id, mixed $instance ): void
hashas( string $id ): bool
getget( 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

ClassBehaviour
Core\Activatoractivate( 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\Deactivatorflush_rewrite_rules() only. No data touched, $network_wide deliberately ignored.
Core\UninstallerPer-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:

OptionConstantContentAutoload
ad_form_settingsOPTION_SETTINGSdelete_data_on_uninstall, store_ip, store_user_agent, debug_logging, analytics_enabledyes
ad_form_db_versionOPTION_DB_VERSIONintyes
ad_form_versionOPTION_VERSIONversion stringyes

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:

CapabilityGrep resultConsequence
Outbound HTTPno wp_remote_* anywhereLicenseApiClient is the plugin's first HTTP client
Schedulingno wp_schedule_event / wp_next_scheduledLicenseScheduler is the first cron consumer
Cachingno set_transient / get_transientno cache conventions to follow; use own option + LicenseCache
Cryptono sodium_*, no openssl_*SignatureVerifier is greenfield
Network optionsno *_site_optionper-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().

TablePurposeLicensing relevance
ad_form_formsForm entity, definition longtext, version, submission_count, starts_at/ends_atSave-time gating target; definition carries all premium config
ad_form_form_metaKey/value per formCandidate store for contains_locked_features
ad_form_submissionsEntry, payload snapshot, payment_status column already presentRuntime gating target
ad_form_submission_metaQueryable field values (value_text, value_num, value_long)Never sent to the license server
ad_form_logschannel, level, message, contextLicensing log sink
ad_form_paymentsgateway, gateway_payment_id, amount, currency, status, modeForm payments (System B) only. Must never hold plugin-license data
ad_form_migrationsApplied-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.

KeyContentNotes
settingsajax, honeypot, require_login, confirmation_type, confirmation_message, redirect_urlconfirmation_type: redirect is the only redirect mechanism today
fields[]id, type, key, label, required, step, settings, conditionsCalculation formula lives in settings.formula
steps[]id, title, description, next_label, prev_labelFields join via field.step
actions[]id, type, enabled, settings, conditionsOnly email is registered
layout{ rows: [] }Persisted placeholder, never rendered. Only sanitised in FormDefinition:72, no reader anywhere
conditionstop-level listPersisted placeholder, never evaluated. ConditionEngine, ActionRunner and SubmitService only read per-field and per-action conditions
stylesfree-form objectNo 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).

typeclassNotes
textTextField
emailEmailField
numberNumberField
textareaTextareaField
selectSelectField
checkboxCheckboxField
calculationCalculationFieldreads settings.formula
submitSubmitField

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() on template_redirect priority 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_validationad_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.

MethodRoutePermission
GET/statuspublic_status (always true)
GET/systemmanage_settings
GET/forms/schemaedit_forms
GET/forms/pickerpick_forms
GET/formsedit_forms
POST/formscreate_forms
POST/forms/(?P<id>\d+)/duplicatecreate_forms
GET/forms/(?P<id>\d+)edit_forms
PUT, PATCH/forms/(?P<id>\d+)edit_forms
DELETE/forms/(?P<id>\d+)delete_forms
GET/fieldsedit_forms
GET/actionsedit_forms
GET/conditionsedit_forms
GET/calculationsedit_forms
POST/forms/(?P<id>\d+)/submitsubmit_form (always true, nonce in handler)
GET/submissionsview_submissions
POST/submissions/bulkedit_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() hooks elementor/loaded; is_available() = did_action('elementor/loaded') > 0 || class_exists('\Elementor\Plugin'); the widget registers only if class_exists('\Elementor\Widget_Base').
  • GutenbergIntegration::register() returns early unless function_exists('register_block_type'); the block also needs a readable assets/build/block.asset.php. ad_form_gutenberg_register fires 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.

InterfaceStatus
Payments\PaymentGatewayInterfaceid(), label(), create_payment( array $order ): array. No implementation. Table exists
Integrations\IntegrationInterfaceid(), label(), is_configured(). No implementation. ad_form_integrations never fired
Core\ModuleInterfaceNo implementation. ad_form_register_modules never fired
Support\ValidationRuleInterfacead_form_validation_rules never fired
Templates\FormRendererInterfaceImplemented by Frontend\FormRenderer
Notifications\MailerInterfaceImplemented 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

AreaDetail
Form builderReact drag & drop (dnd.ts), autosave, undo/redo, field duplicate, optimistic-concurrency save via version
Form CRUDCreate, read, update, delete, duplicate, slug uniqueness, 4 statuses (draft, published, disabled, archived), schedule window
Fields8 types: text, email, number, textarea, select, checkbox, calculation, submit
ValidationRequired + per-type format validation server-side; browser-native + JS client-side
Conditional logicConditionEngine, 10 operators, show/hide, all/any; per field and per action; server-authoritative
CalculationsCalculationEngine with a safe parser, min/max/abs/round, 4-pass resolution, calculation field
Multi-stepStepEngine, progress display, Back/Next, single submit. Frontend-only paging
NotificationsEmailAction with merge tags (MergeTagEngine), HTML/plain modes, per-action conditional sending, WpMailer
ConfirmationInline message or redirect (settings.confirmation_type)
SubmissionsPersist + payload snapshot + queryable meta, entries list, detail view, read/unread, star, spam, trash, restore, permanent delete, bulk operations
FrontendShortcode, AJAX and classic POST submit, conditional asset enqueue, FormGate availability rules
GutenbergBlock ad-form/form with published-form picker
ElementorWidget ad-form with published-form picker
SecurityPer-form submit nonce, honeypot, capability model (8 caps), allow-list sanitiser, log redaction, IP hashing/anonymisation options
LoggingLogger to ad_form_logs, 4 levels, channel-based, debug gated by settings
Privacy controlsstore_ip (none/hashed/anonymized/raw), store_user_agent, opt-in data deletion on uninstall
InfrastructureContainer, 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.

#LayerAnchorChange
I-01BootstrapPlugin::register_providers()Insert LicensingServiceProvider after DatabaseServiceProvider
I-02IdentityCore\Activator::activate_site()Seed installation_uuid if absent; never regenerate
I-03SchedulerLicensingServiceProvider::boot()wp_next_scheduled + jittered daily event; first cron use in the plugin
I-04Save gateFormService::assert_definition()Third constructor arg FormEntitlementValidator, run after FormDefinitionValidator
I-05Runtime gateActionRunner::run() line 84Deny by entitlement before ad_form_before_action, reuse skip-and-log
I-06REST gateAbstractRestController + per-controllerdeny_feature() returning 403 feature_not_entitled
I-07CatalogueFieldRegistry::to_rest_array(), ActionRegistry::to_rest_array()Add required_entitlements + locked; feeds GET /fields, GET /actions and the builder in one place
I-08Field metadataAbstractField::required_entitlements()New defaulted method on the abstract, not on FieldInterface
I-09Action metadataAbstractAction::required_entitlements()Same shape
I-10Module registryCore\ModuleInterface + new ModuleRegistryDead interface, safe to extend with required_entitlements()
I-11ElementorElementorIntegration::register()Entitlement check beside the elementor/loaded host check
I-12GutenbergGutenbergIntegration::register()Entitlement check beside the register_block_type host check
I-13Admin menuMenu::register(), View::TEMPLATES, new templateLicense page
I-14Builder configAdmin\Assets::enqueue_builder()entitlements + upgradeUrl in window.adFormBuilder; never the token
I-15Builder UIPalette.tsx, Inspector.tsx, types.tsPlan badges, lock affordance, LockedFeature component
I-16Downgrade markerad_form_form_metacontains_locked_features + admin notice on the forms list
I-17LoggingLogging\Loggerlicense channel; extend redaction with license_key, installation_token
I-18UninstallCore\UninstallerInclude 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

#RiskImpactMitigation
R-01Branch divergence: main has Phase 1 only, real code is the phase-11 tipLicensing built on main would target a skeletonBuild on the phase-11 tree; land phases 2–11 into main before or with Phase 1 of licensing
R-02FormService::assert_definition() also runs for update() on forms saved under a higher planA Free user could be unable to save an unrelated title change on an old Pro+ formDiff-based enforcement: reject only newly added or modified premium config, allow pass-through of pre-existing config. Explicit test required
R-03duplicate() re-enters create()Duplicating a Pro+ form on Free would hard-failDecide deliberately: block duplication with a clear message, or copy and mark contains_locked_features
R-04Autosave every 1500 ms hits PATCH /forms/{id}A rejecting save gate turns into an autosave error loop in the builderBuilder must surface feature_not_entitled distinctly and stop retrying; server response must be idempotent
R-05Unknown extra keys are preserved through sanitisationCannot rely on sanitisation to strip premium configExplicit detection in FormEntitlementValidator; treat preservation as a feature for downgrades
R-06Actions run on ad_form_after_submission, after persistenceA denied payment action still leaves a stored entryIntended. Document it: entry retained, paid execution refused, payment_status left untouched
R-07No server-side step validationMulti-step has no runtime surfaceGate multi-step at save time and in UI only; do not invent a runtime check
R-08163 tests rely on hand-written WP stubs in tests/bootstrap.phpwp_remote_post, wp_schedule_event, sodium_*, wp_next_scheduled are unavailable to testsExtend the stub layer and inject an HTTP transport interface so LicenseApiClient is testable without network
R-09License state in an autoloaded optionEvery request pays the deserialisation costKeep the payload compact, autoload only the hot entitlement slice, keep the signed blob in a non-autoloaded option
R-10Per-site options on multisiteA 200-site network would activate 200 installationsDecide the multisite policy up front (see the plan); send network context as a signal and let the API decide
R-11Site URL changes (http→https, www, migration, staging clone)Naive string comparison would deactivate working installationsNever compare URLs for equality client-side; report the URL and let the API decide
R-12Ed25519 unavailable on an exotic hostSignature verification impossibleGuard on function_exists( 'sodium_crypto_sign_verify_detached' ), fail closed to Free with an admin notice, never fall back to a weaker algorithm
R-13ad_form_payments and Stripe naming collision between System A and System BCustomer payment data mixed with license billingLicensing writes no payment table, stores no Stripe identifier, and uses only plan / license_status / entitlements
R-14Entitlement lookups inside render and submit loopsFrontend regressionResolve entitlements once per request from cache; forbid HTTP in the render path
R-15Categories advanced / business / wordpress exist unused on AbstractFieldCategory and plan could be conflatedCategories 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.

Digitale Lösungen. Persönlich. Zukunftssicher.