Skip to content

ADR 0004 — Feature gating ​

Status: accepted Date: 2026-08-21

Context ​

The plugin needs roughly 93 licensable features across four plans. The naive approach — checking the plan at each call site — fails in several ways at once: moving a feature between plans becomes a code change, monthly and yearly variants drift apart, and every new call site is a chance to forget the check.

The audited codebase had no feature flags at all, so there was no existing pattern to inherit and no legacy to be compatible with.

Decision ​

Entitlement keys, one authority, asked identically from every layer.

Forbidden everywhere:

php
if ( 'business' === $plan ) {}
if ( $is_pro ) {}
if ( AD_FORM_PRO ) {}

The only form:

php
$entitlements->allows( 'payments.stripe' );
  • FeatureRegistry pins 93 features, each with key, name, category, dependencies, marketing_plan and a core flag.
  • EntitlementManager resolves once per request: the Free baseline from the registry, plus the signed entitlements expanded through their dependencies, but only while LicenseState grants paid entitlements.
  • marketing_plan drives badges and upgrade copy. It never decides access.
  • Compound requirements use allows_all(), which returns the missing keys. Stripe needs both payments and payments.stripe.
  • An entitlement the server grants but this build does not know is honoured, so a newer platform can sell a feature before every site updates.
  • Field, action and module requirements are declared on the type itself, so licensing logic exists once per feature rather than once per layer.

required_entitlements() lives on AbstractField and AbstractAction, not on FieldInterface and ActionInterface. Adding a method to those interfaces would break any add-on already implementing them; a defaulted method on the abstract cannot, and the registries read it through an instanceof check.

Enforcement happens at every layer that can reach a feature: builder UI, builder config, save validation, REST, module registration, action execution and runtime policy. UI alone is never sufficient.

Consequences ​

Good:

  • The dashboard can move a feature between plans with no plugin release.
  • Monthly and yearly are identical by construction; the billing interval is never consulted.
  • A new field type declares one method and is gated in the registry, the scanner, the save gate, the palette and the REST catalogue at once.
  • Enforcement is testable as a matrix. 525 tests cover it.

Costs:

  • 93 keys to maintain, and a new feature must remember to register one. Mitigated by docs/FEATURE_ENFORCEMENT_AUDIT.md, where a missing gate shows up as a gap.
  • The registry duplicates plan knowledge that also exists on the platform. Only for presentation; a disagreement affects a badge, not access.

No escape hatch ​

There is no ad_form_entitlements filter, no writable registry and no license-state filter. FeatureRegistry::register() refuses any key that is already a core key, so an add-on cannot re-register payments.stripe as Free. ModuleGate likewise refuses to let a core module id be redefined.

Every licensing hook is an action and none can change an outcome.

Rejected ​

Plan comparison at the call site. Forbidden by the product requirement, and it makes plan composition a code change.

A $is_pro boolean. Cannot express three paid tiers or per-integration plans.

A constant such as AD_FORM_PRO. Definable by the site owner in wp-config.php, which is a one-line bypass.

Entitlements on the interfaces. Breaks existing add-ons for no gain.

Filtering the REST type catalogue. The catalogue is marketing information; hiding it would only stop customers discovering what they could buy, while the save gate refuses the type regardless.

Digitale Lösungen. Persönlich. Zukunftssicher.