# Divi Essential Licensing — Policy Pivot Spec

**Release version:** 5.5.1 (still)
**Date:** 2026-04-19
**Status:** Supersedes frontend-lock behavior in [2026-04-19-licensing-system-design.md](2026-04-19-licensing-system-design.md)

---

## Why this revision exists

The original licensing spec locked modules at the render layer — unlicensed users saw blank space or placeholders on the frontend, and the builder palette hid DE modules. This had two problems:

1. **Breaks live customer sites on license lapse.** If a paying customer's license expired while their site was in production, visitors to their site would suddenly see blank sections. Support tickets would cascade.
2. **Over-broad scope.** The lock affected both "can this visitor see the module" and "can the admin add/edit modules." These are separate concerns that deserve separate answers.

User's revised policy, stated directly:
- **No license / expired / revoked:** user cannot edit DE modules, but modules stay visible on the frontend.
- **Active license:** full access.

This pivot moves the lock from the render layer to the builder edit layer. Live sites keep working no matter what the license says. The lock only affects actions taken inside the Divi Builder (adding new modules, editing existing ones).

---

## New state → behavior mapping

| State | EDD status | Frontend render | Builder palette (add new) | Builder edit (existing) | DE admin pages |
|---|---|---|---|---|---|
| `LICENSED` | `valid` | ✅ renders | ✅ allowed | ✅ allowed | accessible |
| `GRANDFATHERED` | `expired`/`disabled`/`revoked`, pin ≥ version | ✅ renders | ❌ hidden | ❌ **blocked with message** | accessible (so they can renew) |
| `LOCKED_BYPASSED` | `expired`/`disabled`/`revoked`, pin < version (ZIP upload) | ✅ renders | ❌ hidden | ❌ blocked with message | accessible |
| `LOCKED_MIGRATION` | `missing`/`invalid`, 30-day grace active | ✅ renders | ✅ allowed | ✅ allowed | accessible |
| `LOCKED` | `missing`/`invalid`, no grace | ✅ renders | ❌ hidden | ❌ blocked with message | **redirect to license page** |
| `LOCKED_STALE` | last verified > 14 days ago | ✅ renders | ❌ hidden | ❌ blocked with message | accessible |

**Two important changes from the original spec:**

1. **`GRANDFATHERED` tightens from "full access" to "view-only in builder."** Paying customers whose license lapsed can still see their modules on live sites but cannot edit them until they renew. Renewal restores full access.
2. **Frontend rendering is unconditional.** All the PHP-side render gating from the original spec is removed.

**Migration grace (`LOCKED_MIGRATION`) deliberately retains full access** — that's the whole point of the 30-day window. Without it, existing unlicensed installs would suddenly lose builder access on the 5.5.1 upgrade.

---

## Code deltas from current (post-Task 15) state

### Reverts (restore frontend rendering)

- [divi-5/server/Modules/Modules.php:147-157](../../../divi-5/server/Modules/Modules.php) — remove the early-return gate. Modules always register on the PHP side.
- [divi-5/divi-5.php](../../../divi-5/divi-5.php) — remove both the `divi_module_wrapper_render` filter and the `the_content` shortcode-strip filter. No frontend lock at all.
- [divi-essential.php:102-116](../../../divi-essential.php) `dnxte_register_module()` — remove the `should_register_modules()` gate. Divi 4 modules always register.

### Gate predicate tightening

In [includes/License/Gate.php](../../../includes/License/Gate.php), the current `is_locked()` predicate serves two different purposes that now need to diverge:

- **Builder lock** (blocks editing DE modules) — tightens to include `GRANDFATHERED`.
- **Admin-page redirect** (bounces DE admin pages to license page) — narrows to only `LOCKED`, so grandfathered users can still reach the dashboard to click "Renew."

**Resolution:** rename `is_locked()` to `is_builder_locked()` with the tightened semantics, and introduce a new `needs_license_page_redirect()` predicate for the Menu.php guard.

```php
/**
 * Returns true if the user should be blocked from editing DE modules
 * in the builder. Grandfathered users are blocked here even though
 * their existing content still renders on the frontend.
 */
public static function is_builder_locked() {
    $state = self::get_effective_state();
    return ! in_array(
        $state,
        [ self::STATE_LICENSED, self::STATE_LOCKED_MIGRATION ],
        true
    );
}

/**
 * Returns true only when the user has never held a valid license and
 * is out of migration grace. Only these users get bounced off DE
 * admin pages to the license activation form; grandfathered users
 * keep dashboard access so they can renew from anywhere.
 */
public static function needs_license_page_redirect() {
    return self::STATE_LOCKED === self::get_effective_state();
}
```

| Old call site | New call |
|---|---|
| `Gate::should_render_module()` | **delete** — rendering is unconditional |
| `Gate::should_register_modules()` | **delete** |
| `Gate::is_locked()` | replace with `Gate::is_builder_locked()` for edit-gate callers, `Gate::needs_license_page_redirect()` for Menu.php |
| `Gate::extensions_enabled()` | keep as-is — extensions still gated on `is_builder_locked()` semantics |
| `Gate::placeholder_html()` | **delete** — no frontend placeholder anymore |

### Visual builder — swap palette gate + add edit-block wrapper

[divi-5/visual-builder/src/index.ts](../../../divi-5/visual-builder/src/index.ts)'s `addAction` callback currently skips registration entirely when locked. Change to:

1. **Always register modules** (so existing modules on pages can be opened in the builder and inspected).
2. **If not LICENSED and not LOCKED_MIGRATION, wrap each module's `renderers.edit` in a license-gated HOC** that shows a lock overlay (UX spec below).
3. **If not LICENSED and not LOCKED_MIGRATION, skip the module from the library palette** — the `registerModule()` call still runs, but we pass a `hideFromLibrary: true` flag *if Divi 5 supports it*. If not, use a client-side filter on module library list.

The distinction: "register the module definition" (so existing instances can be displayed) vs "show it in the palette" (which we want hidden). These are separate in Divi 5's architecture — registration is universal, palette inclusion is filtered.

**Exact approach:**

```typescript
const LICENSE_ACTIVE_STATES = new Set(["LICENSED", "LOCKED_MIGRATION"]);

function isBuilderLocked(): boolean {
    const state = window.divi_essential?.licenseState;
    return !!state && !LICENSE_ACTIVE_STATES.has(state);
}

function wrapEditLocked<P>(OriginalEdit: React.ComponentType<P>, moduleLabel: string) {
    return function LockedEdit(props: P) {
        return isBuilderLocked()
            ? <LicenseLockOverlay moduleLabel={moduleLabel} />
            : <OriginalEdit {...props} />;
    };
}

// In the addAction callback, replace the current `if (!allowRegistration) return;`
// with per-module wrapping:

Object.entries(moduleMapping).forEach(([moduleKey, moduleComponent]) => {
    if (inactive_modules.includes(moduleKey)) return;

    const register = (component: any) => {
        if (!component || !("metadata" in component)) return;
        const rest = omit(component, "metadata");
        const moduleLabel = component.metadata?.settings?.title ?? moduleKey;
        const wrappedRenderers = {
            ...rest.renderers,
            edit: wrapEditLocked(rest.renderers.edit, moduleLabel),
        };
        registerModule(component.metadata, { ...rest, renderers: wrappedRenderers });
    };

    if (Array.isArray(moduleComponent)) {
        moduleComponent.forEach(register);
    } else {
        register(moduleComponent);
    }
});
```

**Palette hiding — primary approach: CSS.** When `isBuilderLocked()` is true, inject a `<style>` tag into the builder admin page that hides DE module entries from the module library container (selector: `[data-module-name^="dnxte/"]` scoped to the library panel, or whatever Divi 5's actual palette DOM structure exposes — resolved at implementation time).

CSS is always available, requires no Divi API knowledge, and fails gracefully (worst case: selector misses and modules stay visible but still can't be added because the edit overlay will block on any actual use). An optional optimization during implementation: if `registerModule` turns out to accept a `hideFromLibrary` or equivalent flag, use that and drop the CSS. Both approaches should not be shipped together — pick one at implementation time.

### Save-block on server (belt-and-suspenders)

Client-side edit block can be bypassed by determined users (bundle patched, DevTools, etc.). Add a REST API filter that rejects save requests for DE modules when `is_builder_locked()`:

- Hook: `rest_pre_dispatch` or the Divi 5 builder's save endpoint (investigate which is used).
- Location: new file `includes/License/SaveGuard.php`.
- Scope: only blocks saves of posts that contain DE module blocks. Non-DE content saves normally.

### Admin menu redirect — narrow scope

[includes/Admin/Menu.php](../../../includes/Admin/Menu.php) currently redirects when `Gate::is_locked()` is true (which includes `LOCKED_BYPASSED`, `LOCKED_STALE`, `LOCKED`). Under the new policy, only `LOCKED` should redirect — grandfathered users need access to the dashboard to hit "Renew License."

Change the guard from `Gate::is_locked()` to `Gate::needs_license_page_redirect()`.

### Admin banners — updated copy

Some banner copy in [includes/License/AdminBanners.php](../../../includes/License/AdminBanners.php) mentions "plugin is locked" for grandfathered/stale/bypassed — adjust to reflect the new policy: "your modules still display on the frontend but the builder is locked for editing until you renew." Keep the banner state machine; just update strings.

---

## Edit-block overlay UX (option "B+")

Rendered via `<LicenseLockOverlay />` in the wrapped edit renderer.

**Layout:**
- Full-width card matching the normal edit-panel container.
- Lock icon ( 🔒 or equivalent).
- Heading: `"{moduleLabel} — License required"` (e.g., "Flip Box — License required").
- Body paragraph: brief description of what the module does (pulled from module metadata if available; otherwise a generic fallback).
- State-specific subline:
  - `LOCKED` → "Activate your Divi Essential license to edit this module."
  - `GRANDFATHERED` / `LOCKED_BYPASSED` → "Your Divi Essential license has expired. Renew to edit this module."
  - `LOCKED_STALE` → "We can't verify your license right now. Retry once your connection to divinext.com is restored."
- Primary CTA: "Renew License" button → opens the DE license admin page in a new tab.
- Secondary link: "Learn more" → links to the marketplace product page.
- Reassurance line at the bottom: "Your existing module will continue to render for site visitors."

**Visual style:** match the existing Divi builder's card/panel look — use Divi 5's design tokens (spacing, border-radius, typography) where available via CSS variables, so the overlay feels native. Avoid aggressive red/warning colors; use muted yellow/neutral tones to communicate "gated" without feeling broken.

---

## Testing scenarios (updated)

Replaces the testing checklist in the original spec. Manual verification on a WP staging install:

1. **Fresh install, no key entered** → state `LOCKED`. DE admin menus redirect to license page. Frontend: no DE modules visible yet (site has none). If a DE module is manually added to a page via database, it renders normally.
2. **Pre-5.5.1 unlicensed install upgrading** → state `LOCKED_MIGRATION`. Full access for 30 days. Banner shows countdown. After deadline, transitions to `LOCKED`.
3. **Active license** → state `LICENSED`. Full builder access. Modules visible in palette. Edit panels work normally.
4. **License expires (transition from LICENSED → GRANDFATHERED)** → existing modules on pages still render for visitors. Opening any DE module in the builder shows the lock overlay. Admin pages still accessible (not redirected).
5. **License revoked via EDD** → same as expired → view-only.
6. **Grandfathered user uploads newer ZIP** → state `LOCKED_BYPASSED`. Same visible behavior as `GRANDFATHERED` (frontend renders, edit blocked).
7. **Grandfathered user renews** → state flips to `LICENSED`. Edit overlay disappears. Palette shows DE modules again.
8. **License server unreachable for < 14 days** → state unchanged from last known.
9. **License server unreachable for > 14 days** → state `LOCKED_STALE`. Edit blocked. Frontend still renders.
10. **Determined user bypasses JS edit block via bundle patch** → server save filter rejects the save request. Edit UI opens if they patched the client, but any save returns a REST error.

---

## Files to create / modify / revert

**Revert (previously added):**
- [divi-5/server/Modules/Modules.php](../../../divi-5/server/Modules/Modules.php) — remove the `should_register_modules()` early-return.
- [divi-5/divi-5.php](../../../divi-5/divi-5.php) — remove both added filters.
- [divi-essential.php](../../../divi-essential.php) `dnxte_register_module()` — remove the `should_register_modules()` gate.

**Modify:**
- [includes/License/Gate.php](../../../includes/License/Gate.php) — rename `is_locked()` → `is_builder_locked()`; tighten semantics (includes GRANDFATHERED, LOCKED_BYPASSED, LOCKED_STALE, LOCKED); add `needs_license_page_redirect()` (LOCKED only); delete `should_render_module()`, `should_register_modules()`, `placeholder_html()`.
- [tests/test-gate.php](../../../tests/test-gate.php) — update tests for new predicate names + tightened GRANDFATHERED semantics.
- [includes/Admin/Menu.php](../../../includes/Admin/Menu.php) — change guard to `needs_license_page_redirect()`.
- [includes/License/AdminBanners.php](../../../includes/License/AdminBanners.php) — update banner copy for new semantics.
- [includes/Admin.php](../../../includes/Admin.php) — `extensions_enabled()` still works; no change needed (the rename cascade only hits the locked-from-builder predicate).
- [divi-essential-updater.php](../../../divi-essential-updater.php) — use `is_builder_locked()` instead of the old `is_locked()` for the transient filter block list. Same effective behavior.
- [divi-5/visual-builder/src/index.ts](../../../divi-5/visual-builder/src/index.ts) — replace current "skip registration" gate with per-module edit-renderer wrapping. Always register. Add palette-hide via either `hideFromLibrary` flag or CSS fallback.

**Create:**
- [divi-5/visual-builder/src/LicenseLockOverlay.tsx](../../../divi-5/visual-builder/src/LicenseLockOverlay.tsx) — the edit-block overlay component.
- [includes/License/SaveGuard.php](../../../includes/License/SaveGuard.php) — server-side save blocker (REST filter).

**No longer needed (delete or leave as no-op):**
- [divi-5/visual-builder/src/license-gate.ts](../../../divi-5/visual-builder/src/license-gate.ts) — the Task 13 module-library filter using an unverified hook. Replaced by the per-module edit wrapping. Delete.

**Rebuild:** `npm run build` in `divi-5/visual-builder/` after JS changes.

---

## Open concerns (to validate during implementation)

- **Does Divi 5's `registerModule` accept a `hideFromLibrary` / `visible` flag?** If yes, palette hiding is trivial. If no, fall back to a CSS rule in the admin. Investigate in the implementation plan.
- **What's the correct REST endpoint for the save-block?** Divi 5 saves via a custom REST route (probably under `/wp-json/divi/v1/*` or similar). Need to identify the exact path and filter hook before implementing `SaveGuard.php`.
- **Module-label lookup for the overlay:** `component.metadata.settings.title` is a guess. Need to verify the correct path to the user-facing module name in each module's metadata.
- **Banner dismissal state:** dismissible banners use per-user transients keyed by state. If a user dismisses the GRANDFATHERED banner, then renews, then lapses again, the new GRANDFATHERED banner should reappear. The current transient key uses `state + user_id`, which resets on state transitions — this should Just Work, but verify.
