# Divi Essential Licensing System — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Enforce license-required access for Divi Essential 5.5.1 — grandfather users whose licenses expired while previously valid, fully lock modules/extensions for unlicensed users on both frontend and admin.

**Architecture:** Single `License\Gate` class resolves every request to one of six states (`LICENSED`, `GRANDFATHERED`, `LOCKED_BYPASSED`, `LOCKED_MIGRATION`, `LOCKED`, `LOCKED_STALE`). Enforcement hooks (frontend module render filter, admin redirect, module-library JS filter, update transient filter) all delegate state decisions to `Gate`. A daily WP-Cron pings EDD Software Licensing at divinext.com for status. No server-side changes required.

**Tech Stack:** PHP 7.4+, WordPress 5.0+, EDD Software Licensing (existing), TypeScript (Divi 5 visual builder), standalone PHP test runner (no PHPUnit/composer).

**Spec:** [docs/superpowers/specs/2026-04-19-licensing-system-design.md](../specs/2026-04-19-licensing-system-design.md)

---

## Pre-flight

Before starting:
- Verify working tree is clean: `cd /Users/ajantadas/Herd/divi-5/wp-content/plugins/divi-essential && git status`
- Confirm plugin currently runs at version 5.5.0 (or whatever was last released).
- Create a feature branch: `git checkout -b feature/licensing-enforcement-5.5.1`
- PHP CLI must be available: `php -v` should return 7.4 or higher.

Note on `// __LICENSE__INFO__` markers in [includes/Admin.php](../../../includes/Admin.php): the project has a build-time toggle that strips code between these markers. **Any new license-gate reference in [includes/Admin.php](../../../includes/Admin.php) must be wrapped in the same marker pair.**

---

## Task 1: Create Gate test harness

Standalone PHP test runner that stubs WordPress functions so the pure-logic `Gate` class can be tested without loading WP.

**Files:**
- Create: `tests/test-gate.php`

- [ ] **Step 1: Create the test file with WP function stubs**

```php
<?php
/**
 * Standalone test runner for DNXTE\Includes\License\Gate.
 *
 * Run: php tests/test-gate.php
 * Exits 0 if all tests pass, 1 if any fail.
 */

if ( 'cli' !== php_sapi_name() ) {
    exit( 'CLI only.' );
}

// --- WordPress constant stubs ---
if ( ! defined( 'ABSPATH' ) )              define( 'ABSPATH', '/tmp/' );
if ( ! defined( 'DIVI_ESSENTIAL_VERSION' ) ) define( 'DIVI_ESSENTIAL_VERSION', '5.5.1' );
if ( ! defined( 'DAY_IN_SECONDS' ) )        define( 'DAY_IN_SECONDS', 86400 );
if ( ! defined( 'DNEXT_ESSENTIAL_PLUGIN_LICENSE_PAGE' ) ) {
    define( 'DNEXT_ESSENTIAL_PLUGIN_LICENSE_PAGE', 'divi-next-essential-license' );
}

// --- WordPress function stubs ---
$GLOBALS['__mock_options']    = [];
$GLOBALS['__mock_can']        = false;
$GLOBALS['__test_failures']   = 0;
$GLOBALS['__test_total']      = 0;

function get_option( $key, $default = false ) {
    return array_key_exists( $key, $GLOBALS['__mock_options'] )
        ? $GLOBALS['__mock_options'][ $key ]
        : $default;
}
function update_option( $key, $value ) {
    $GLOBALS['__mock_options'][ $key ] = $value;
    return true;
}
function delete_option( $key ) {
    unset( $GLOBALS['__mock_options'][ $key ] );
    return true;
}
function current_user_can( $cap ) {
    return (bool) ( $GLOBALS['__mock_can'] ?? false );
}
function admin_url( $path = '' ) {
    return 'http://localhost/wp-admin/' . $path;
}
function esc_url( $url )                   { return htmlspecialchars( $url ); }
function esc_html( $text )                 { return htmlspecialchars( $text ); }
function esc_html__( $text, $domain = '' ) { return $text; }
function esc_attr( $text )                 { return htmlspecialchars( $text ); }
function __( $text, $domain = '' )         { return $text; }

// --- Load Gate ---
require_once __DIR__ . '/../includes/License/Gate.php';

use DNXTE\Includes\License\Gate;

// --- Assertion helper ---
function assert_equal( $expected, $actual, string $label ) {
    $GLOBALS['__test_total']++;
    if ( $expected === $actual ) {
        echo "  ok  - $label\n";
        return;
    }
    $GLOBALS['__test_failures']++;
    $e = var_export( $expected, true );
    $a = var_export( $actual, true );
    echo "  FAIL - $label\n";
    echo "         expected: $e\n";
    echo "         actual:   $a\n";
}

function reset_mocks() {
    $GLOBALS['__mock_options'] = [];
    $GLOBALS['__mock_can']     = false;
}

echo "Running Gate tests...\n\n";

// Placeholder — actual test cases added in Task 2.

// --- Report ---
echo "\n" . $GLOBALS['__test_total'] . " tests, " . $GLOBALS['__test_failures'] . " failures\n";
exit( $GLOBALS['__test_failures'] > 0 ? 1 : 0 );
```

- [ ] **Step 2: Verify harness loads (expected: fatal — Gate class doesn't exist yet)**

```bash
cd /Users/ajantadas/Herd/divi-5/wp-content/plugins/divi-essential
php tests/test-gate.php
```

Expected: PHP fatal error "Failed opening required '.../includes/License/Gate.php'". That's fine — Task 2 creates the Gate file and first tests.

- [ ] **Step 3: Commit**

```bash
git add tests/test-gate.php
git commit -m "test: add standalone Gate test harness with WP function stubs"
```

---

## Task 2: Gate state machine (TDD)

Write failing tests for all six states, then implement `Gate::get_effective_state()`.

**Files:**
- Modify: `tests/test-gate.php` (add test cases)
- Create: `includes/License/Gate.php`

- [ ] **Step 1: Add failing tests for all six states**

Replace the `// Placeholder — ...` line in `tests/test-gate.php` with:

```php
// --- LICENSED ---
reset_mocks();
$GLOBALS['__mock_options']['dnext_essential_license_status']         = 'valid';
$GLOBALS['__mock_options']['dnext_essential_license_last_verified']  = time();
assert_equal( 'LICENSED', Gate::get_effective_state(), 'status=valid, recent verification → LICENSED' );

// --- GRANDFATHERED ---
reset_mocks();
$GLOBALS['__mock_options']['dnext_essential_license_status']        = 'expired';
$GLOBALS['__mock_options']['dnext_essential_grandfather_version']   = '5.5.1';
$GLOBALS['__mock_options']['dnext_essential_license_last_verified'] = time();
assert_equal( 'GRANDFATHERED', Gate::get_effective_state(), 'status=expired + pin >= current → GRANDFATHERED' );

reset_mocks();
$GLOBALS['__mock_options']['dnext_essential_license_status']        = 'disabled';
$GLOBALS['__mock_options']['dnext_essential_grandfather_version']   = '6.0.0';
$GLOBALS['__mock_options']['dnext_essential_license_last_verified'] = time();
assert_equal( 'GRANDFATHERED', Gate::get_effective_state(), 'status=disabled + pin > current → GRANDFATHERED' );

reset_mocks();
$GLOBALS['__mock_options']['dnext_essential_license_status']        = 'revoked';
$GLOBALS['__mock_options']['dnext_essential_grandfather_version']   = '5.5.1';
$GLOBALS['__mock_options']['dnext_essential_license_last_verified'] = time();
assert_equal( 'GRANDFATHERED', Gate::get_effective_state(), 'status=revoked + pin = current → GRANDFATHERED' );

// --- LOCKED_BYPASSED ---
reset_mocks();
$GLOBALS['__mock_options']['dnext_essential_license_status']        = 'expired';
$GLOBALS['__mock_options']['dnext_essential_grandfather_version']   = '5.4.0';
$GLOBALS['__mock_options']['dnext_essential_license_last_verified'] = time();
assert_equal( 'LOCKED_BYPASSED', Gate::get_effective_state(), 'status=expired + pin < current (ZIP upload) → LOCKED_BYPASSED' );

// --- LOCKED_MIGRATION ---
reset_mocks();
$GLOBALS['__mock_options']['dnext_essential_license_status']        = 'missing';
$GLOBALS['__mock_options']['dnext_essential_lock_deadline']         = time() + ( 15 * DAY_IN_SECONDS );
$GLOBALS['__mock_options']['dnext_essential_license_last_verified'] = time();
assert_equal( 'LOCKED_MIGRATION', Gate::get_effective_state(), 'status=missing + deadline in future → LOCKED_MIGRATION' );

reset_mocks();
$GLOBALS['__mock_options']['dnext_essential_license_status']        = 'invalid';
$GLOBALS['__mock_options']['dnext_essential_lock_deadline']         = time() + ( 1 * DAY_IN_SECONDS );
$GLOBALS['__mock_options']['dnext_essential_license_last_verified'] = time();
assert_equal( 'LOCKED_MIGRATION', Gate::get_effective_state(), 'status=invalid + deadline in future → LOCKED_MIGRATION' );

// --- LOCKED ---
reset_mocks();
$GLOBALS['__mock_options']['dnext_essential_license_status']        = 'missing';
$GLOBALS['__mock_options']['dnext_essential_license_last_verified'] = time();
assert_equal( 'LOCKED', Gate::get_effective_state(), 'status=missing, no deadline → LOCKED' );

reset_mocks();
$GLOBALS['__mock_options']['dnext_essential_license_status']        = 'missing';
$GLOBALS['__mock_options']['dnext_essential_lock_deadline']         = time() - 60; // expired deadline
$GLOBALS['__mock_options']['dnext_essential_license_last_verified'] = time();
assert_equal( 'LOCKED', Gate::get_effective_state(), 'status=missing, deadline passed → LOCKED' );

reset_mocks();
$GLOBALS['__mock_options']['dnext_essential_license_last_verified'] = time(); // no status at all
assert_equal( 'LOCKED', Gate::get_effective_state(), 'no status and no key → LOCKED' );

// --- LOCKED_STALE ---
reset_mocks();
$GLOBALS['__mock_options']['dnext_essential_license_status']        = 'valid';
$GLOBALS['__mock_options']['dnext_essential_license_last_verified'] = time() - ( 15 * DAY_IN_SECONDS );
assert_equal( 'LOCKED_STALE', Gate::get_effective_state(), 'last verified 15 days ago → LOCKED_STALE (overrides LICENSED)' );

reset_mocks();
$GLOBALS['__mock_options']['dnext_essential_license_status']        = 'expired';
$GLOBALS['__mock_options']['dnext_essential_grandfather_version']   = '5.5.1';
$GLOBALS['__mock_options']['dnext_essential_license_last_verified'] = time() - ( 20 * DAY_IN_SECONDS );
assert_equal( 'LOCKED_STALE', Gate::get_effective_state(), 'GRANDFATHERED but stale → LOCKED_STALE' );
```

- [ ] **Step 2: Run tests — expect fatal (Gate class missing)**

```bash
php tests/test-gate.php
```

Expected: fatal error about `DNXTE\Includes\License\Gate`.

- [ ] **Step 3: Create `includes/License/Gate.php` with state resolver**

```php
<?php
namespace DNXTE\Includes\License;

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

/**
 * Central license-state resolver for Divi Essential.
 *
 * Every enforcement point (frontend render, builder library, admin redirect,
 * update transient) calls Gate::get_effective_state() — no duplicated logic.
 */
class Gate {

    const STATE_LICENSED         = 'LICENSED';
    const STATE_GRANDFATHERED    = 'GRANDFATHERED';
    const STATE_LOCKED_BYPASSED  = 'LOCKED_BYPASSED';
    const STATE_LOCKED_MIGRATION = 'LOCKED_MIGRATION';
    const STATE_LOCKED           = 'LOCKED';
    const STATE_LOCKED_STALE     = 'LOCKED_STALE';

    const PREVIOUSLY_VALID_STATES = [ 'expired', 'disabled', 'revoked' ];
    const STALE_THRESHOLD         = 14 * DAY_IN_SECONDS;

    /**
     * Resolve the plugin's effective license state for this request.
     *
     * @return string One of the STATE_* constants.
     */
    public static function get_effective_state() {
        $status            = (string) get_option( 'dnext_essential_license_status', '' );
        $grandfather       = (string) get_option( 'dnext_essential_grandfather_version', '' );
        $last_verified     = (int) get_option( 'dnext_essential_license_last_verified', 0 );
        $lock_deadline     = (int) get_option( 'dnext_essential_lock_deadline', 0 );
        $current_version   = defined( 'DIVI_ESSENTIAL_VERSION' ) ? DIVI_ESSENTIAL_VERSION : '0.0.0';

        // Stale-server override: if we haven't had a verified response in > 14 days,
        // force LOCKED_STALE regardless of other state.
        if ( $last_verified > 0 && ( time() - $last_verified ) > self::STALE_THRESHOLD ) {
            return self::STATE_LOCKED_STALE;
        }

        if ( $status === 'valid' ) {
            return self::STATE_LICENSED;
        }

        if ( in_array( $status, self::PREVIOUSLY_VALID_STATES, true ) ) {
            if ( $grandfather !== '' && version_compare( $grandfather, $current_version, '>=' ) ) {
                return self::STATE_GRANDFATHERED;
            }
            return self::STATE_LOCKED_BYPASSED;
        }

        // status is empty, 'missing', 'invalid', or anything else
        if ( $lock_deadline > 0 && $lock_deadline > time() ) {
            return self::STATE_LOCKED_MIGRATION;
        }

        return self::STATE_LOCKED;
    }
}
```

- [ ] **Step 4: Run tests — expect all pass**

```bash
php tests/test-gate.php
```

Expected output ends with: `12 tests, 0 failures`, exit code 0.

- [ ] **Step 5: Commit**

```bash
git add includes/License/Gate.php tests/test-gate.php
git commit -m "feat(license): add Gate state machine with 12 test cases"
```

---

## Task 3: Gate predicates and placeholder HTML

Helper predicates used by enforcement points, plus the admin-visible placeholder rendered when a module is locked.

**Files:**
- Modify: `tests/test-gate.php` (add predicate tests)
- Modify: `includes/License/Gate.php` (add predicate methods)

- [ ] **Step 1: Append predicate tests to `tests/test-gate.php`**

Add at the end of the test file, just before the `// --- Report ---` line:

```php
// --- Predicates ---
reset_mocks();
$GLOBALS['__mock_options']['dnext_essential_license_status']        = 'valid';
$GLOBALS['__mock_options']['dnext_essential_license_last_verified'] = time();
assert_equal( true,  Gate::should_render_module(),   'LICENSED → should_render_module true' );
assert_equal( true,  Gate::should_register_modules(), 'LICENSED → should_register_modules true' );
assert_equal( true,  Gate::extensions_enabled(),      'LICENSED → extensions_enabled true' );
assert_equal( false, Gate::is_locked(),               'LICENSED → is_locked false' );

reset_mocks();
$GLOBALS['__mock_options']['dnext_essential_license_status']        = 'expired';
$GLOBALS['__mock_options']['dnext_essential_grandfather_version']   = '5.5.1';
$GLOBALS['__mock_options']['dnext_essential_license_last_verified'] = time();
assert_equal( true,  Gate::should_render_module(),   'GRANDFATHERED → should_render_module true' );
assert_equal( true,  Gate::should_register_modules(), 'GRANDFATHERED → should_register_modules true' );
assert_equal( true,  Gate::extensions_enabled(),      'GRANDFATHERED → extensions_enabled true' );
assert_equal( false, Gate::is_locked(),               'GRANDFATHERED → is_locked false' );

reset_mocks();
$GLOBALS['__mock_options']['dnext_essential_license_status']        = 'missing';
$GLOBALS['__mock_options']['dnext_essential_lock_deadline']         = time() + ( 5 * DAY_IN_SECONDS );
$GLOBALS['__mock_options']['dnext_essential_license_last_verified'] = time();
assert_equal( true,  Gate::should_render_module(),   'LOCKED_MIGRATION → should_render_module true (still in grace)' );
assert_equal( true,  Gate::should_register_modules(), 'LOCKED_MIGRATION → should_register_modules true' );
assert_equal( true,  Gate::extensions_enabled(),      'LOCKED_MIGRATION → extensions_enabled true' );
assert_equal( false, Gate::is_locked(),               'LOCKED_MIGRATION → is_locked false' );

reset_mocks();
$GLOBALS['__mock_options']['dnext_essential_license_status']        = 'missing';
$GLOBALS['__mock_options']['dnext_essential_license_last_verified'] = time();
assert_equal( false, Gate::should_render_module(),   'LOCKED → should_render_module false' );
assert_equal( false, Gate::should_register_modules(), 'LOCKED → should_register_modules false' );
assert_equal( false, Gate::extensions_enabled(),      'LOCKED → extensions_enabled false' );
assert_equal( true,  Gate::is_locked(),               'LOCKED → is_locked true' );

reset_mocks();
$GLOBALS['__mock_options']['dnext_essential_license_status']        = 'expired';
$GLOBALS['__mock_options']['dnext_essential_grandfather_version']   = '5.4.0';
$GLOBALS['__mock_options']['dnext_essential_license_last_verified'] = time();
assert_equal( false, Gate::should_render_module(),   'LOCKED_BYPASSED → should_render_module false' );
assert_equal( true,  Gate::is_locked(),               'LOCKED_BYPASSED → is_locked true' );

// --- Placeholder HTML ---
reset_mocks();
$html = Gate::placeholder_html( 'dnxte/text-animation' );
assert_equal( true, strpos( $html, 'License required' ) !== false,
    'placeholder_html contains "License required"' );
assert_equal( true, strpos( $html, 'dnxte-license-placeholder' ) !== false,
    'placeholder_html contains dnxte-license-placeholder class' );
```

- [ ] **Step 2: Run tests — predicate tests fail (methods don't exist)**

```bash
php tests/test-gate.php
```

Expected: fatal or FAIL for `should_render_module`, `should_register_modules`, `is_locked`, `extensions_enabled`, `placeholder_html`.

- [ ] **Step 3: Append predicate + placeholder methods to `includes/License/Gate.php`**

Add just before the final closing `}` of the `Gate` class:

```php
    /**
     * Full access states — module render, library visibility, extensions.
     *
     * @return bool
     */
    public static function is_locked() {
        $state = self::get_effective_state();
        return ! in_array(
            $state,
            [ self::STATE_LICENSED, self::STATE_GRANDFATHERED, self::STATE_LOCKED_MIGRATION ],
            true
        );
    }

    public static function should_render_module() {
        return ! self::is_locked();
    }

    public static function should_register_modules() {
        return ! self::is_locked();
    }

    public static function extensions_enabled() {
        return ! self::is_locked();
    }

    /**
     * Admin-only placeholder for locked modules on the frontend.
     * Returns an empty string for users without edit_posts capability.
     */
    public static function placeholder_html( $module_name = '' ) {
        if ( ! current_user_can( 'edit_posts' ) ) {
            return '';
        }

        $license_url = esc_url(
            admin_url( 'admin.php?page=' . DNEXT_ESSENTIAL_PLUGIN_LICENSE_PAGE )
        );

        $style = 'padding:16px;border:1px dashed #ccc;background:#fff8e5;'
               . 'color:#6b5900;font-family:sans-serif;border-radius:4px;'
               . 'margin:8px 0;';

        $heading = esc_html__( 'Divi Essential — License required', 'dnxte-divi-essential' );
        $body    = esc_html__(
            'This module requires an active Divi Essential license to render.',
            'dnxte-divi-essential'
        );
        $action  = esc_html__( 'Activate your license', 'dnxte-divi-essential' );

        return sprintf(
            '<div class="dnxte-license-placeholder" style="%s">'
            . '<strong>%s</strong><p>%s <a href="%s">%s</a>.</p></div>',
            esc_attr( $style ),
            $heading,
            $body,
            $license_url,
            $action
        );
    }
```

- [ ] **Step 4: Run tests — all pass**

```bash
php tests/test-gate.php
```

Expected: `24 tests, 0 failures`.

- [ ] **Step 5: Commit**

```bash
git add includes/License/Gate.php tests/test-gate.php
git commit -m "feat(license): add Gate predicates and placeholder HTML"
```

---

## Task 4: Licensing.php — typo fix and activation flow

Fix the `dnwoo_` typo and extend `ajax_activate_license` to write grandfather/last_verified state and clear lock_deadline on a valid activation.

**Files:**
- Modify: `includes/Admin/Licensing.php` (line 391 typo; lines 420-434 activation flow)

- [ ] **Step 1: Read current state of Licensing.php around lines 385-435**

```bash
sed -n '385,435p' includes/Admin/Licensing.php
```

Confirm current code matches what's expected below before editing.

- [ ] **Step 2: Fix `dnwoo_` typo**

Find line 391:
```php
update_option( 'dnwoo_essential_license_status','expired' );
```

Replace with:
```php
update_option( 'dnext_essential_license_status', 'expired' );
update_option( 'dnext_essential_license_last_verified', time() );
```

- [ ] **Step 3: Update the valid-activation handler**

Replace lines 420-434 (the block starting with `// $license_data->license will be either "valid" or "invalid"` through `wp_send_json_success(...)`) with:

```php
// $license_data->license will be either "valid" or "invalid"
update_option( 'dnext_essential_license_status', $license_data->license );
update_option( 'dnext_essential_license_last_verified', time() );

$active_state = array( 'valid', 'expired' );
if ( in_array( $license_data->license, $active_state, true ) ) {
    update_option( 'dnxte_inactive_modules', array() );
}

if ( 'valid' === $license_data->license ) {
    // Pin the grandfather version and clear any migration grace.
    update_option( 'dnext_essential_grandfather_version', DIVI_ESSENTIAL_VERSION );
    delete_option( 'dnext_essential_lock_deadline' );

    // Flush WP's plugin-update transient so update notices reappear within the next cycle.
    delete_site_transient( 'update_plugins' );
}

wp_send_json_success( array(
    'message' => __( 'License activated successfully!', 'dnxte-divi-essential' ),
    'status'  => $license_data->license,
) );
```

- [ ] **Step 4: Lint-check the file**

```bash
php -l includes/Admin/Licensing.php
```

Expected: `No syntax errors detected`.

- [ ] **Step 5: Commit**

```bash
git add includes/Admin/Licensing.php
git commit -m "fix(license): correct dnwoo typo and persist grandfather state on activation"
```

---

## Task 5: Migration logic for 5.5.1

Add the migration block in `do_after_update()` that runs once when upgrading to 5.5.1. Also wire a `plugins_loaded` priority 9 hook so frontend-only requests still trigger migration.

**Files:**
- Modify: `divi-essential.php` (extend `do_after_update()` and `init_plugin()`)

- [ ] **Step 1: Add the 5.5.1 migration block to `do_after_update()`**

Locate [`divi-essential.php:263-342`](../../../divi-essential.php) (the `do_after_update` method). At the end of the method body — just before the closing `}` and the final `$inactive_extensions = get_option(...)` line — add:

```php
// --- 5.5.1 licensing migration ---
if ( version_compare( $current_version, '5.5.1', '<' ) ) {
    $status                  = get_option( 'dnext_essential_license_status', '' );
    $previously_valid_states = array( 'expired', 'disabled', 'revoked' );

    if ( 'valid' === $status ) {
        // Active licensed user — pin the grandfather version for future expiry.
        update_option( 'dnext_essential_grandfather_version', DIVI_ESSENTIAL_VERSION );
        update_option( 'dnext_essential_license_last_verified', time() );
    } elseif ( in_array( $status, $previously_valid_states, true ) ) {
        // Was licensed at some point — grandfather at current version.
        update_option( 'dnext_essential_grandfather_version', DIVI_ESSENTIAL_VERSION );
        update_option( 'dnext_essential_license_last_verified', time() );
    } else {
        // No prior license record — grant 30-day migration grace.
        update_option(
            'dnext_essential_lock_deadline',
            time() + ( 30 * DAY_IN_SECONDS )
        );
    }

    update_option( 'divi_essential_version', DIVI_ESSENTIAL_VERSION );
}
// --- /5.5.1 licensing migration ---
```

- [ ] **Step 2: Register `plugins_loaded` hook so frontend-only visitors also run migration**

The hook registration must go in the **constructor** (which runs at plugin-file load, before `plugins_loaded` fires), not inside `init_plugin()` (which is itself a `plugins_loaded` callback — WP does not re-dispatch the same hook).

Locate the constructor at [`divi-essential.php:51-61`](../../../divi-essential.php) `private function __construct()`. Find this block:

```php
register_activation_hook( __FILE__, array( $this, 'activate' ) );
add_action( 'plugins_loaded', array( $this, 'init_plugin' ) );
add_action( 'admin_init', array( $this, 'do_after_update' ), 100 );
```

Add one line directly after the `admin_init` registration:

```php
add_action( 'plugins_loaded', array( $this, 'do_after_update' ), 9 );
```

Rationale: `do_after_update` is already idempotent via the `version_compare( $current_version, ..., '<' )` guard. Registering it on two hooks (priority 9 on `plugins_loaded`, priority 100 on `admin_init`) is safe — the second call becomes a no-op because `divi_essential_version` has been updated. Priority 9 ensures migration happens before `init_plugin`'s default priority 10 registration of Cron hooks.

- [ ] **Step 3: Lint-check**

```bash
php -l divi-essential.php
```

Expected: `No syntax errors detected`.

- [ ] **Step 4: Smoke test — confirm `do_after_update` compiles when called**

Quick sanity via a temporary script (don't commit this):

```bash
php -r "
define('ABSPATH', '/tmp/');
function get_option(\$k, \$d = false) { return \$d; }
function update_option(\$k, \$v) { return true; }
function delete_option(\$k) { return true; }
function add_action(...\$a) {}
function add_filter(...\$a) {}
function register_activation_hook(...\$a) {}
function plugin_basename(\$f) { return basename(\$f); }
function plugin_dir_url(\$f) { return ''; }
function plugin_dir_path(\$f) { return dirname(\$f) . '/'; }
function wp_get_theme() { return new stdClass(); }
function get_file_data() { return ['Version' => '5.5.1']; }
if (!defined('DAY_IN_SECONDS')) define('DAY_IN_SECONDS', 86400);
require 'divi-essential.php';
echo 'Syntax OK, plugin bootstrapped.' . PHP_EOL;
" 2>&1 | head -5
```

Expected: no fatal PHP errors in the first few lines. WP-specific warnings are fine — we're only checking syntax + class structure.

- [ ] **Step 5: Commit**

```bash
git add divi-essential.php
git commit -m "feat(license): add 5.5.1 migration logic with 30-day grace and grandfather pinning"
```

---

## Task 6: Cron class — daily license check

Daily WP-Cron handler that pings divinext.com via EDD's `check_license` action. Idempotent per-day, tolerant to server errors.

**Files:**
- Create: `includes/License/Cron.php`

- [ ] **Step 1: Create the Cron class**

```php
<?php
namespace DNXTE\Includes\License;

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

/**
 * Daily license verification against divinext.com (EDD Software Licensing).
 *
 * Usage:
 *   \DNXTE\Includes\License\Cron::register_schedule();   // call on activation
 *   \DNXTE\Includes\License\Cron::unregister_schedule(); // call on deactivation
 *
 * Hooks:
 *   - `dnext_essential_daily_license_check` — cron action, fires hourly as a
 *     safety net; internally no-ops unless 24 hours have elapsed.
 *   - `wp_ajax_dnext_essential_manual_recheck` — admin-only manual trigger.
 */
class Cron {

    const HOOK           = 'dnext_essential_daily_license_check';
    const RECHECK_WINDOW = DAY_IN_SECONDS;

    public static function register_hooks() {
        add_action( self::HOOK, array( __CLASS__, 'run_check' ) );
        add_action( 'wp_ajax_dnext_essential_manual_recheck', array( __CLASS__, 'ajax_manual_recheck' ) );

        // Also ensure the event is scheduled — register_activation_hook does not
        // fire for plugin upgrades, so existing installs upgrading to 5.5.1 would
        // otherwise never have the cron scheduled. wp_schedule_event is idempotent
        // via the wp_next_scheduled() guard.
        self::register_schedule();
    }

    public static function register_schedule() {
        if ( ! wp_next_scheduled( self::HOOK ) ) {
            wp_schedule_event( time() + 60, 'hourly', self::HOOK );
        }
    }

    public static function unregister_schedule() {
        $ts = wp_next_scheduled( self::HOOK );
        while ( $ts ) {
            wp_unschedule_event( $ts, self::HOOK );
            $ts = wp_next_scheduled( self::HOOK );
        }
    }

    /**
     * Cron handler. No-ops if last verification was within RECHECK_WINDOW.
     */
    public static function run_check( $force = false ) {
        $last = (int) get_option( 'dnext_essential_license_last_verified', 0 );
        if ( ! $force && $last > 0 && ( time() - $last ) < self::RECHECK_WINDOW ) {
            return;
        }

        $key = trim( (string) get_option( 'dnext_essential_license_key', '' ) );

        // No key on record — user is unlicensed; stamp status and exit.
        if ( '' === $key ) {
            update_option( 'dnext_essential_license_status', 'missing' );
            update_option( 'dnext_essential_license_last_verified', time() );
            return;
        }

        if ( ! defined( 'DNEXT_ESSENTIAL_STORE_URL' ) || ! defined( 'DNEXT_ESSENTIAL_ITEM_ID' ) ) {
            // Licensing constants not loaded yet — skip silently.
            return;
        }

        $response = wp_remote_post(
            DNEXT_ESSENTIAL_STORE_URL,
            array(
                'timeout'   => 15,
                'sslverify' => false,
                'body'      => array(
                    'edd_action' => 'check_license',
                    'license'    => $key,
                    'item_id'    => DNEXT_ESSENTIAL_ITEM_ID,
                    'item_name'  => defined( 'DNEXT_ESSENTIAL_ITEM_NAME' ) ? DNEXT_ESSENTIAL_ITEM_NAME : '',
                    'url'        => home_url(),
                ),
                'headers'   => array(
                    'Content-Type' => 'application/x-www-form-urlencoded',
                ),
            )
        );

        // HTTP-level failure: do NOT touch license_status or last_verified.
        if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
            update_option( 'dnext_essential_last_check_error', time() );
            return;
        }

        $body = json_decode( wp_remote_retrieve_body( $response ) );

        if ( ! is_object( $body ) || ! isset( $body->license ) ) {
            update_option( 'dnext_essential_last_check_error', time() );
            return;
        }

        $new_status = sanitize_text_field( (string) $body->license );

        update_option( 'dnext_essential_license_status', $new_status );
        update_option( 'dnext_essential_license_last_verified', time() );

        if ( 'valid' === $new_status ) {
            update_option( 'dnext_essential_grandfather_version', DIVI_ESSENTIAL_VERSION );
            delete_option( 'dnext_essential_lock_deadline' );
            delete_site_transient( 'update_plugins' );
        }
    }

    /**
     * Admin-only AJAX handler for a "Recheck license now" button on the
     * license page. Forces a check regardless of the 24-hour window.
     */
    public static function ajax_manual_recheck() {
        if ( ! current_user_can( 'manage_options' ) ) {
            wp_send_json_error( array( 'message' => __( 'Permission denied.', 'dnxte-divi-essential' ) ), 403 );
        }
        if ( ! check_ajax_referer( MODULES_NONCE, 'nonce', false ) ) {
            wp_send_json_error( array( 'message' => __( 'Security check failed.', 'dnxte-divi-essential' ) ), 403 );
        }

        self::run_check( true );

        wp_send_json_success( array(
            'status'        => get_option( 'dnext_essential_license_status', '' ),
            'last_verified' => (int) get_option( 'dnext_essential_license_last_verified', 0 ),
        ) );
    }
}
```

- [ ] **Step 2: Lint-check**

```bash
php -l includes/License/Cron.php
```

Expected: `No syntax errors detected`.

- [ ] **Step 3: Commit**

```bash
git add includes/License/Cron.php
git commit -m "feat(license): add daily Cron verifier with 24h idempotent guard"
```

---

## Task 7: Plugin bootstrap wiring

Register the Cron hooks, schedule the event on activation, unschedule on deactivation, and wire the daily-check action.

**Files:**
- Modify: `divi-essential.php` (activate/deactivate hooks; init_plugin() registration)

- [ ] **Step 1: Register the deactivation hook**

In [`divi-essential.php`](../../../divi-essential.php), locate the constructor around line 51-61:

```php
register_activation_hook( __FILE__, array( $this, 'activate' ) );
```

Add immediately after that line:

```php
register_deactivation_hook( __FILE__, array( $this, 'deactivate' ) );
```

- [ ] **Step 2: Schedule cron on activation**

Locate the `activate()` method (around line 221). At the very end of the method body (after `$this->popup_pro_builder_support();` and before the closing `}`), add:

```php
// Schedule daily license check.
\DNXTE\Includes\License\Cron::register_schedule();
```

- [ ] **Step 3: Add the `deactivate()` method**

Add a new method right after `activate()`:

```php
/**
 * Plugin deactivation cleanup. Mirrors activate().
 */
public function deactivate() {
    \DNXTE\Includes\License\Cron::unregister_schedule();
}
```

- [ ] **Step 4: Register Cron hooks in `init_plugin()`**

Locate `init_plugin()` (around line 181). After the line `include DIVI_ESSENTIAL_DIR . '/includes/functions.php';`, add:

```php
// License subsystem hooks.
\DNXTE\Includes\License\Cron::register_hooks();
```

- [ ] **Step 5: Lint-check**

```bash
php -l divi-essential.php
```

Expected: `No syntax errors detected`.

- [ ] **Step 6: Commit**

```bash
git add divi-essential.php
git commit -m "feat(license): register Cron on activate, unregister on deactivate"
```

---

## Task 8: Frontend render gates (Divi 5 + Divi 4)

Two filters in [`divi-5/divi-5.php`](../../../divi-5/divi-5.php): one wraps Divi 5 module render output, the other intercepts legacy Divi 4 `[et_pb_dnxte_*]` shortcodes in post content.

**Files:**
- Modify: `divi-5/divi-5.php`

- [ ] **Step 1: Inspect the `divi_module_wrapper_render` filter signature**

```bash
grep -rn "divi_module_wrapper_render" /Users/ajantadas/Herd/divi-5/wp-content/themes/Divi 2>/dev/null | head -5
grep -rn "divi_module_wrapper_render" divi-5/server 2>/dev/null | head -5
grep -rn "filter_wrapper_render" divi-5 2>/dev/null | head -5
```

The signature is `function( $rendered_output, $context ) : string` where `$context` is an array whose `'name'` key holds the module name (e.g. `'dnxte/text-animation'`). If grep reveals a different shape, adjust the code below to match — the enforcement logic is the same regardless of parameter shape.

- [ ] **Step 2: Read the current tail of `divi-5/divi-5.php`**

```bash
tail -40 divi-5/divi-5.php
```

Note the file's closing state so you can append the new filter cleanly.

- [ ] **Step 3: Add the Divi 5 render gate**

Append before the closing `?>` (or at EOF if no closing tag) in [`divi-5/divi-5.php`](../../../divi-5/divi-5.php):

```php
// --- License gate: Divi 5 module render ---
add_filter(
    'divi_module_wrapper_render',
    function ( $rendered, $context = array() ) {
        if ( ! class_exists( '\\DNXTE\\Includes\\License\\Gate' ) ) {
            return $rendered;
        }

        $module_name = '';
        if ( is_array( $context ) && isset( $context['name'] ) ) {
            $module_name = (string) $context['name'];
        } elseif ( is_object( $context ) && isset( $context->name ) ) {
            $module_name = (string) $context->name;
        }

        // Only gate modules registered by this plugin.
        if ( strpos( $module_name, 'dnxte/' ) !== 0 ) {
            return $rendered;
        }

        if ( \DNXTE\Includes\License\Gate::should_render_module() ) {
            return $rendered;
        }

        return \DNXTE\Includes\License\Gate::placeholder_html( $module_name );
    },
    10,
    2
);
```

- [ ] **Step 4: Add the Divi 4 shortcode gate**

Append to the same file:

```php
// --- License gate: Divi 4 legacy shortcodes in post content ---
add_filter(
    'the_content',
    function ( $content ) {
        if ( ! class_exists( '\\DNXTE\\Includes\\License\\Gate' ) ) {
            return $content;
        }
        if ( \DNXTE\Includes\License\Gate::should_render_module() ) {
            return $content;
        }

        // Strip [et_pb_dnxte_*] shortcodes (opening, content, and closing tags).
        $pattern = '/\[\/?et_pb_dnxte_[^\]]*\]/';
        $stripped = preg_replace( $pattern, '', $content );

        // For admin users, replace the first occurrence with a placeholder so
        // they can see the lock explanation. Otherwise render empty.
        if ( $stripped !== $content && current_user_can( 'edit_posts' ) ) {
            $placeholder = \DNXTE\Includes\License\Gate::placeholder_html( 'divi-4-legacy' );
            // Prepend once so the placeholder is visible.
            return $placeholder . $stripped;
        }

        return $stripped !== null ? $stripped : $content;
    },
    9999
);
```

- [ ] **Step 5: Lint-check**

```bash
php -l divi-5/divi-5.php
```

Expected: `No syntax errors detected`.

- [ ] **Step 6: Commit**

```bash
git add divi-5/divi-5.php
git commit -m "feat(license): gate Divi 5 module render and Divi 4 shortcodes when locked"
```

---

## Task 9: Extensions gate + Divi 4 module registration gate

Wrap conditional extension loads in [`includes/Admin.php`](../../../includes/Admin.php) with `Gate::extensions_enabled()`. Add a registration gate to `dnxte_register_module()` in [`divi-essential.php`](../../../divi-essential.php).

**CRITICAL:** All license-gate references added to [`includes/Admin.php`](../../../includes/Admin.php) must be wrapped in `// __LICENSE__INFO__` ... `// !__LICENSE__INFO__` markers so the build-time stripping tool handles them correctly.

**Files:**
- Modify: `includes/Admin.php` (wrap conditional requires at lines 49-69 and the module-related `new` at lines 22-24)
- Modify: `divi-essential.php` (gate `dnxte_register_module()` around line 102)

- [ ] **Step 1: Read the current Admin.php constructor**

```bash
sed -n '17,70p' includes/Admin.php
```

Confirm the layout matches what's expected below.

- [ ] **Step 2: Gate the module-related admin classes**

In [`includes/Admin.php`](../../../includes/Admin.php), the constructor currently instantiates (lines 19-30):

```php
new Assets();
new Menu();
new Ajax();
new SocialProofAjax();
new DataTableAjax();
new \DNXTE\Includes\Modules\SpinningWheel\AdminMenu();
// __LICENSE__INFO__
$licensing = new Licensing();
$licensing->define_constants();
$licensing->include_updater();
$licensing->actions();
// !__LICENSE__INFO__
```

Replace the `SocialProofAjax`, `DataTableAjax`, and `SpinningWheel\AdminMenu` lines with:

```php
new Assets();
new Menu();
new Ajax();
// __LICENSE__INFO__
if ( \DNXTE\Includes\License\Gate::extensions_enabled() ) {
    new SocialProofAjax();
    new DataTableAjax();
    new \DNXTE\Includes\Modules\SpinningWheel\AdminMenu();
}
// !__LICENSE__INFO__
$licensing = new Licensing();
$licensing->define_constants();
$licensing->include_updater();
$licensing->actions();
```

Keep `Assets`, `Menu`, `Ajax`, and `Licensing` always-loaded — these contain license-page admin assets, menu registration, and the license AJAX handlers that must remain reachable in a locked state.

Note: the existing `__LICENSE__INFO__` markers remain around the Licensing block. A new marker pair is added around the `extensions_enabled()` check.

- [ ] **Step 3: Gate the conditional extension `require_once` blocks**

In the same file, the extension loads at lines ~49-69 currently look like:

```php
$visibility_feature = get_option( 'dnxte_inactive_extensions', array() );
add_action( 'wp_ajax_dnxte_save_enhance_project_posts', array( $this, 'save_enhance_project_posts' ) );
if ( ! in_array( 'dnxte-customizable-project-posts', $visibility_feature, true ) ) {
    require_once DIVI_ESSENTIAL_DIR . 'includes/Extensions/enhance-project/enhance.php';
    require_once DIVI_ESSENTIAL_DIR . 'includes/Extensions/enhance-project/dashboard.php';
}

// Extended Font Type.
if ( ! in_array( 'extended-font-type', $visibility_feature, true ) ) {
    require_once DIVI_ESSENTIAL_DIR . 'includes/Extensions/extended-font-upload/dashboard.php';
    add_action( 'wp_ajax_dnxte_save_font_uploads', array( 'Dnxte_Extended_Font_Upload', 'dnxte_save_font_uploads_callback' ) );
}

// Maintenance Mode.
if ( ! in_array( 'dnxte-maintenance-mode', $visibility_feature, true ) ) {
    require_once DIVI_ESSENTIAL_DIR . 'includes/Extensions/maintenance-mode/dashboard.php';
    add_action( 'wp_ajax_dnxte_save_maintenance_mode', array( 'Dnxte_Maintenance_Mode', 'dnxte_save_maintenance_mode_callback' ) );
}

// Browser Scrollbar - Always load (regardless of active status) to show customizer notice.
require_once DIVI_ESSENTIAL_DIR . 'includes/Extensions/browser-scrollbar/dashboard.php';
```

Wrap the entire block (the `$visibility_feature = ...` line through the Browser Scrollbar require) with the license gate:

```php
// __LICENSE__INFO__
if ( \DNXTE\Includes\License\Gate::extensions_enabled() ) {
// !__LICENSE__INFO__

    $visibility_feature = get_option( 'dnxte_inactive_extensions', array() );
    add_action( 'wp_ajax_dnxte_save_enhance_project_posts', array( $this, 'save_enhance_project_posts' ) );
    if ( ! in_array( 'dnxte-customizable-project-posts', $visibility_feature, true ) ) {
        require_once DIVI_ESSENTIAL_DIR . 'includes/Extensions/enhance-project/enhance.php';
        require_once DIVI_ESSENTIAL_DIR . 'includes/Extensions/enhance-project/dashboard.php';
    }

    // Extended Font Type.
    if ( ! in_array( 'extended-font-type', $visibility_feature, true ) ) {
        require_once DIVI_ESSENTIAL_DIR . 'includes/Extensions/extended-font-upload/dashboard.php';
        add_action( 'wp_ajax_dnxte_save_font_uploads', array( 'Dnxte_Extended_Font_Upload', 'dnxte_save_font_uploads_callback' ) );
    }

    // Maintenance Mode.
    if ( ! in_array( 'dnxte-maintenance-mode', $visibility_feature, true ) ) {
        require_once DIVI_ESSENTIAL_DIR . 'includes/Extensions/maintenance-mode/dashboard.php';
        add_action( 'wp_ajax_dnxte_save_maintenance_mode', array( 'Dnxte_Maintenance_Mode', 'dnxte_save_maintenance_mode_callback' ) );
    }

    // Browser Scrollbar - Always load (regardless of active status) to show customizer notice.
    require_once DIVI_ESSENTIAL_DIR . 'includes/Extensions/browser-scrollbar/dashboard.php';

// __LICENSE__INFO__
}
// !__LICENSE__INFO__
```

The marker pair wraps the `if` keyword alone at top and the closing `}` alone at bottom so that a stripping tool can replace both with empty strings and leave the extension code intact (unconditionally loaded) in builds without licensing.

- [ ] **Step 4: Gate Divi 4 module registration**

In [`divi-essential.php`](../../../divi-essential.php), locate `dnxte_register_module()` around line 98-106:

```php
public function dnxte_register_module() {
    if ( file_exists( DIVI_ESSENTIAL_DIR . 'divi-4/server/modules/modules.php' ) ) {
        require_once DIVI_ESSENTIAL_DIR . 'divi-4/server/modules/modules.php';
    }
}
```

Replace with:

```php
public function dnxte_register_module() {
    if (
        class_exists( '\\DNXTE\\Includes\\License\\Gate' )
        && ! \DNXTE\Includes\License\Gate::should_register_modules()
    ) {
        return;
    }
    if ( file_exists( DIVI_ESSENTIAL_DIR . 'divi-4/server/modules/modules.php' ) ) {
        require_once DIVI_ESSENTIAL_DIR . 'divi-4/server/modules/modules.php';
    }
}
```

Note: existing comments on this method (the "Always load" docstring) are intentionally left untouched per user preference — the function now skips load conditionally, but only via the Gate.

- [ ] **Step 5: Lint-check both files**

```bash
php -l includes/Admin.php && php -l divi-essential.php
```

Expected: `No syntax errors detected` for both.

- [ ] **Step 6: Commit**

```bash
git add includes/Admin.php divi-essential.php
git commit -m "feat(license): gate extensions and Divi 4 module registration when locked"
```

---

## Task 10: Admin menu redirect

When locked, redirect all DE admin pages (except the license page itself) to the license activation page.

**Files:**
- Modify: `includes/Admin/Menu.php`

- [ ] **Step 1: Inspect current Menu.php structure**

```bash
head -80 includes/Admin/Menu.php
```

Identify a suitable place to add the `admin_init` hook — typically inside a constructor or an existing `actions()`-style method. If the class has no existing hook registration point, register the hook at class load time outside the class definition (bottom of the file) so it fires once.

- [ ] **Step 2: Add the redirect hook**

Append this block at the end of [`includes/Admin/Menu.php`](../../../includes/Admin/Menu.php), after the closing `}` of the `Menu` class:

```php

// --- License redirect: bounce locked users off DE admin pages except the license page ---
add_action(
    'admin_init',
    function () {
        if ( ! class_exists( '\\DNXTE\\Includes\\License\\Gate' ) ) {
            return;
        }
        if ( ! \DNXTE\Includes\License\Gate::is_locked() ) {
            return;
        }
        if ( ! defined( 'DNEXT_ESSENTIAL_PLUGIN_LICENSE_PAGE' ) ) {
            return;
        }

        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only page check, no state mutation.
        if ( empty( $_GET['page'] ) ) {
            return;
        }

        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
        $page = sanitize_text_field( wp_unslash( $_GET['page'] ) );

        // Only redirect pages registered by this plugin (prefix 'dnxte-').
        if ( 0 !== strpos( $page, 'dnxte-' ) ) {
            return;
        }

        if ( DNEXT_ESSENTIAL_PLUGIN_LICENSE_PAGE === $page ) {
            return;
        }

        wp_safe_redirect( admin_url( 'admin.php?page=' . DNEXT_ESSENTIAL_PLUGIN_LICENSE_PAGE ) );
        exit;
    }
);
```

- [ ] **Step 3: Lint-check**

```bash
php -l includes/Admin/Menu.php
```

Expected: `No syntax errors detected`.

- [ ] **Step 4: Commit**

```bash
git add includes/Admin/Menu.php
git commit -m "feat(license): redirect locked users from DE admin pages to license page"
```

---

## Task 11: Admin banners

State-specific admin notices. Dismissible only for `GRANDFATHERED` and `LOCKED_MIGRATION` (per-session dismissal via a transient).

**Files:**
- Create: `includes/License/AdminBanners.php`

- [ ] **Step 1: Create the AdminBanners class**

```php
<?php
namespace DNXTE\Includes\License;

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

/**
 * State-specific admin notices. Dismissible states use a short-lived user
 * transient ("dismissed this session").
 */
class AdminBanners {

    const DISMISS_NONCE  = 'dnext_essential_banner_dismiss';
    const DISMISS_ACTION = 'dnext_essential_dismiss_banner';

    public static function register_hooks() {
        add_action( 'admin_notices', array( __CLASS__, 'render' ) );
        add_action( 'wp_ajax_' . self::DISMISS_ACTION, array( __CLASS__, 'ajax_dismiss' ) );
    }

    public static function render() {
        if ( ! class_exists( '\\DNXTE\\Includes\\License\\Gate' ) ) {
            return;
        }
        if ( ! current_user_can( 'manage_options' ) ) {
            return;
        }

        $state = Gate::get_effective_state();

        if ( Gate::STATE_LICENSED === $state ) {
            return;
        }

        if ( self::is_dismissed( $state ) ) {
            return;
        }

        $license_url = esc_url( admin_url( 'admin.php?page=' . DNEXT_ESSENTIAL_PLUGIN_LICENSE_PAGE ) );
        $version     = defined( 'DIVI_ESSENTIAL_VERSION' ) ? DIVI_ESSENTIAL_VERSION : '';
        $pinned      = (string) get_option( 'dnext_essential_grandfather_version', '' );

        switch ( $state ) {
            case Gate::STATE_GRANDFATHERED:
                self::emit(
                    'notice-warning',
                    sprintf(
                        /* translators: 1: pinned version, 2: renewal URL. */
                        esc_html__(
                            'Your Divi Essential license expired. The plugin remains functional at v%1$s. %2$sRenew to receive updates.%3$s',
                            'dnxte-divi-essential'
                        ),
                        esc_html( $pinned ),
                        '<a href="' . $license_url . '">',
                        '</a>'
                    ),
                    true
                );
                break;

            case Gate::STATE_LOCKED_MIGRATION:
                $deadline = (int) get_option( 'dnext_essential_lock_deadline', 0 );
                $days     = $deadline > 0 ? max( 0, (int) ceil( ( $deadline - time() ) / DAY_IN_SECONDS ) ) : 0;
                $class    = $days >= 14 ? 'notice-warning' : 'notice-error';
                self::emit(
                    $class,
                    sprintf(
                        /* translators: 1: days remaining, 2: license page URL. */
                        esc_html( _n(
                            'Divi Essential will lock in %1$d day unless a license is activated. %2$sActivate now%3$s',
                            'Divi Essential will lock in %1$d days unless a license is activated. %2$sActivate now%3$s',
                            $days,
                            'dnxte-divi-essential'
                        ) ),
                        $days,
                        '<a href="' . $license_url . '">',
                        '</a>'
                    ),
                    true
                );
                break;

            case Gate::STATE_LOCKED_BYPASSED:
                self::emit(
                    'notice-error',
                    sprintf(
                        /* translators: 1: grandfather version, 2: current version, 3: license URL. */
                        esc_html__(
                            'Version mismatch. Your grandfathered version is v%1$s; you installed v%2$s. Plugin locked — %3$srenew license%4$s or reinstall v%1$s.',
                            'dnxte-divi-essential'
                        ),
                        esc_html( $pinned ),
                        esc_html( $version ),
                        '<a href="' . $license_url . '">',
                        '</a>'
                    ),
                    false
                );
                break;

            case Gate::STATE_LOCKED:
                self::emit(
                    'notice-error',
                    sprintf(
                        /* translators: 1: license URL. */
                        esc_html__(
                            'Divi Essential is locked. %1$sActivate your license%2$s.',
                            'dnxte-divi-essential'
                        ),
                        '<a href="' . $license_url . '">',
                        '</a>'
                    ),
                    false
                );
                break;

            case Gate::STATE_LOCKED_STALE:
                self::emit(
                    'notice-warning',
                    sprintf(
                        /* translators: 1: license URL. */
                        esc_html__(
                            'License server unreachable for 14+ days. Divi Essential is locked until we can verify your license. %1$sRetry now%2$s.',
                            'dnxte-divi-essential'
                        ),
                        '<a href="' . $license_url . '">',
                        '</a>'
                    ),
                    false
                );
                break;
        }
    }

    private static function emit( $class, $message_html, $dismissible ) {
        $attrs = 'class="notice ' . esc_attr( $class ) . ( $dismissible ? ' is-dismissible' : '' ) . '" data-dnxte-banner="1"';
        echo '<div ' . $attrs . '><p>' . $message_html . '</p></div>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $message_html is pre-escaped above.
    }

    private static function dismiss_key( $state ) {
        $user = get_current_user_id();
        return 'dnext_essential_banner_dismissed_' . $state . '_' . $user;
    }

    private static function is_dismissed( $state ) {
        if ( ! in_array( $state, array( Gate::STATE_GRANDFATHERED, Gate::STATE_LOCKED_MIGRATION ), true ) ) {
            return false;
        }
        return (bool) get_transient( self::dismiss_key( $state ) );
    }

    public static function ajax_dismiss() {
        if ( ! current_user_can( 'manage_options' ) ) {
            wp_send_json_error( array( 'message' => 'Permission denied.' ), 403 );
        }
        if ( ! check_ajax_referer( self::DISMISS_NONCE, 'nonce', false ) ) {
            wp_send_json_error( array( 'message' => 'Security check failed.' ), 403 );
        }
        $state = isset( $_POST['state'] ) ? sanitize_text_field( wp_unslash( $_POST['state'] ) ) : '';
        if ( '' === $state ) {
            wp_send_json_error( array( 'message' => 'Missing state.' ), 400 );
        }
        set_transient( self::dismiss_key( $state ), 1, 12 * HOUR_IN_SECONDS );
        wp_send_json_success();
    }
}
```

- [ ] **Step 2: Register the hook in `init_plugin()`**

In [`divi-essential.php`](../../../divi-essential.php) `init_plugin()`, below the line that registers `Cron::register_hooks()`, add:

```php
\DNXTE\Includes\License\AdminBanners::register_hooks();
```

- [ ] **Step 3: Lint-check**

```bash
php -l includes/License/AdminBanners.php && php -l divi-essential.php
```

Expected: both report `No syntax errors detected`.

- [ ] **Step 4: Commit**

```bash
git add includes/License/AdminBanners.php divi-essential.php
git commit -m "feat(license): add state-specific dismissible admin banners"
```

---

## Task 12: Pass license state to Visual Builder via wp_localize_script

Expose the effective state to the Divi 5 visual builder's JS bundle so the library filter (Task 13) can read it.

**Files:**
- Modify: `includes/Frontend/Assets.php` (extend `wp_localize_script` call at lines 46-53)

- [ ] **Step 1: Read the current localize_script block**

```bash
sed -n '46,54p' includes/Frontend/Assets.php
```

Current code:

```php
wp_localize_script(
    'divi_essential_main',
    'divi_essential',
    array(
        'plugin_dir'       => DIVI_ESSENTIAL_URL,
        'inactive_modules' => get_option( 'dnxte_inactive_modules', array() ),
    )
);
```

- [ ] **Step 2: Extend the array with `licenseState`**

Replace that block with:

```php
$license_state = class_exists( '\\DNXTE\\Includes\\License\\Gate' )
    ? \DNXTE\Includes\License\Gate::get_effective_state()
    : 'LICENSED';

wp_localize_script(
    'divi_essential_main',
    'divi_essential',
    array(
        'plugin_dir'       => DIVI_ESSENTIAL_URL,
        'inactive_modules' => get_option( 'dnxte_inactive_modules', array() ),
        'licenseState'     => $license_state,
    )
);
```

The `class_exists` guard keeps this file safe even if the license subsystem fails to load — the builder falls back to full access.

- [ ] **Step 3: Lint-check**

```bash
php -l includes/Frontend/Assets.php
```

Expected: `No syntax errors detected`.

- [ ] **Step 4: Commit**

```bash
git add includes/Frontend/Assets.php
git commit -m "feat(license): expose licenseState to visual-builder JS"
```

---

## Task 13: Visual builder library filter (TypeScript + rebuild)

Hide Divi Essential modules from the Divi 5 module-library palette when the state is anything other than `LICENSED` or `GRANDFATHERED`. Existing modules on pages remain editable — only the "Add Module" list is filtered.

**Files:**
- Create: `divi-5/visual-builder/src/license-gate.ts`
- Modify: `divi-5/visual-builder/src/index.ts` (import the new file)

- [ ] **Step 1: Create `license-gate.ts`**

```typescript
/**
 * License Gate — Divi 5 module library filter.
 *
 * When the PHP-localized `divi_essential.licenseState` is anything other than
 * LICENSED or GRANDFATHERED, filter dnxte/* modules out of the Add Module
 * palette. Existing instances on the page remain editable.
 */

declare global {
    interface Window {
        divi_essential?: {
            licenseState?: string;
            plugin_dir?: string;
            inactive_modules?: string[];
        };
        wp?: {
            hooks?: {
                addFilter: (hook: string, namespace: string, cb: Function, priority?: number) => void;
            };
        };
    }
}

const UNLOCKED_STATES = new Set(['LICENSED', 'GRANDFATHERED']);

function isLocked(): boolean {
    const state = window.divi_essential?.licenseState;
    if (!state) return false; // conservative: allow when flag missing
    return !UNLOCKED_STATES.has(state);
}

/**
 * Filter the Divi 5 module library registrations.
 *
 * The exact filter hook name depends on Divi 5's internal API. Divi exposes
 * a `divi.moduleLibrary.moduleList` (or similar) filter via `wp.hooks`. If
 * that name is wrong in your build, search the Divi 5 source for the filter
 * used when populating the module palette and adjust the string below.
 */
export function registerLicenseGate(): void {
    if (!isLocked()) return;
    if (!window.wp?.hooks?.addFilter) return;

    window.wp.hooks.addFilter(
        'divi.moduleLibrary.moduleList',
        'dnxte/license-gate',
        (modules: Array<{ name?: string }>) => {
            if (!Array.isArray(modules)) return modules;
            return modules.filter((m) => {
                const name = typeof m?.name === 'string' ? m.name : '';
                return name.indexOf('dnxte/') !== 0;
            });
        }
    );
}

registerLicenseGate();
```

- [ ] **Step 2: Import the file from `index.ts`**

Open [`divi-5/visual-builder/src/index.ts`](../../../divi-5/visual-builder/src/index.ts). Add near the top of the file (after any existing imports, before module registrations):

```typescript
import './license-gate';
```

If the file already has side-effect imports like `import './something';`, place the new line in the same grouping.

- [ ] **Step 3: Verify the Divi 5 filter hook name**

The filter string `divi.moduleLibrary.moduleList` is a best-guess placeholder. Before running the build, grep Divi 5 theme source for the actual hook:

```bash
grep -rn "moduleLibrary\|module-library\|addFilter" /Users/ajantadas/Herd/divi-5/wp-content/themes/Divi/includes 2>/dev/null | grep -i "hook\|addFilter" | head -20
```

If the correct filter is different (e.g. `divi.module.library.items`, `divi.builder.moduleList`), update the string in `license-gate.ts` before building.

- [ ] **Step 4: Rebuild the Divi 5 bundle**

```bash
cd /Users/ajantadas/Herd/divi-5/wp-content/plugins/divi-essential/divi-5/visual-builder
npm run build
```

Expected: build completes successfully. `131 warnings are normal (Dart Sass deprecation)` per the project memory. No errors.

- [ ] **Step 5: Verify the bundle contains the new code**

```bash
grep -c "licenseState\|dnxte/license-gate" /Users/ajantadas/Herd/divi-5/wp-content/plugins/divi-essential/divi-5/scripts/bundle.js
```

Expected: count > 0 (both strings appear somewhere in the compiled bundle).

- [ ] **Step 6: Commit**

```bash
cd /Users/ajantadas/Herd/divi-5/wp-content/plugins/divi-essential
git add divi-5/visual-builder/src/license-gate.ts divi-5/visual-builder/src/index.ts divi-5/scripts/bundle.js
git commit -m "feat(license): hide DE modules from Divi 5 library palette when locked"
```

---

## Task 14: Updater hardening — strip updates for non-LICENSED states

Belt-and-suspenders local filter on `pre_set_site_transient_update_plugins`. EDD should already suppress updates for non-valid licenses server-side — this guards against misconfiguration.

**Files:**
- Modify: `divi-essential-updater.php` (append filter at bottom of file)

- [ ] **Step 1: Read the end of the updater file**

```bash
tail -20 divi-essential-updater.php
```

Identify the file's closing structure (either a closing `?>` or EOF). The new filter registration should go at the bottom of the file, outside any class definition.

- [ ] **Step 2: Append the filter**

Append to [`divi-essential-updater.php`](../../../divi-essential-updater.php), after the class's closing `}` and before any closing `?>`:

```php
// --- License-aware update suppression ---
// Strip DE from WP's plugin-update transient when license state does not permit updates.
add_filter(
    'pre_set_site_transient_update_plugins',
    function ( $transient ) {
        if ( ! is_object( $transient ) ) {
            return $transient;
        }
        if ( ! class_exists( '\\DNXTE\\Includes\\License\\Gate' ) ) {
            return $transient;
        }

        $state = \DNXTE\Includes\License\Gate::get_effective_state();

        $blocked = array(
            \DNXTE\Includes\License\Gate::STATE_GRANDFATHERED,
            \DNXTE\Includes\License\Gate::STATE_LOCKED,
            \DNXTE\Includes\License\Gate::STATE_LOCKED_BYPASSED,
            \DNXTE\Includes\License\Gate::STATE_LOCKED_MIGRATION,
            \DNXTE\Includes\License\Gate::STATE_LOCKED_STALE,
        );

        if ( ! in_array( $state, $blocked, true ) ) {
            return $transient;
        }

        if ( ! defined( 'DIVI_ESSENTIAL_FILE' ) ) {
            return $transient;
        }

        $basename = plugin_basename( DIVI_ESSENTIAL_FILE );

        if ( isset( $transient->response ) && is_array( $transient->response ) ) {
            unset( $transient->response[ $basename ] );
        }
        if ( isset( $transient->no_update ) && is_array( $transient->no_update ) ) {
            unset( $transient->no_update[ $basename ] );
        }

        return $transient;
    },
    999
);
```

- [ ] **Step 3: Lint-check**

```bash
php -l divi-essential-updater.php
```

Expected: `No syntax errors detected`.

- [ ] **Step 4: Commit**

```bash
git add divi-essential-updater.php
git commit -m "feat(license): strip update transient entries for non-LICENSED states"
```

---

## Task 15: Version bump + changelog

Bump `DIVI_ESSENTIAL_VERSION` to `5.5.1` and add a changelog entry so EDD's updater picks up the right version string.

**Files:**
- Modify: `divi-essential.php` (plugin header `Version:`)
- Modify: `changelog.txt`

- [ ] **Step 1: Bump the plugin header version**

In [`divi-essential.php`](../../../divi-essential.php) at the top of the file, find line 6:

```
Version:     5.5.0
```

Change to:

```
Version:     5.5.1
```

- [ ] **Step 2: Add a changelog entry**

Read the current top of changelog.txt:

```bash
head -20 changelog.txt
```

Prepend a new entry following the existing format. Example format (adapt to what's already there):

```
## 5.5.1 — 2026-04-19
- NEW: License enforcement. Divi Essential now requires an active license for new installations.
  Existing unlicensed installs receive a 30-day grace period; customers whose license expired
  while previously valid are grandfathered at the version installed when expiry was detected.
- NEW: Admin banners surface license state (active / grandfathered / grace countdown / locked).
- NEW: Daily license verification against divinext.com with 14-day grace on server outages.
- FIX: Corrected a typo where license-expired status was stored under the wrong option key.
- FIX: Plugin update notices are now correctly suppressed for expired licenses.
```

- [ ] **Step 3: Verify version is consistent**

```bash
grep -n "Version" divi-essential.php | head -5
grep "^## " changelog.txt | head -3
```

Both should show `5.5.1`.

- [ ] **Step 4: Commit**

```bash
git add divi-essential.php changelog.txt
git commit -m "chore(release): bump to 5.5.1 with license-enforcement changelog"
```

---

## Task 16: Manual verification of all 10 spec scenarios

Run through each scenario from the spec's Testing Checklist on a local WordPress install. This is the final acceptance gate — any failure here means an earlier task is incomplete.

**Files:** None modified. This is verification only.

- [ ] **Step 1: Confirm Gate unit tests still pass**

```bash
cd /Users/ajantadas/Herd/divi-5/wp-content/plugins/divi-essential
php tests/test-gate.php
```

Expected: `24 tests, 0 failures`.

- [ ] **Step 2: Scenario 1 — Fresh install, no key**

1. Deactivate and uninstall Divi Essential.
2. Delete wp_options rows: `dnext_essential_license_key`, `dnext_essential_license_status`, `dnext_essential_grandfather_version`, `dnext_essential_license_last_verified`, `dnext_essential_lock_deadline`, `divi_essential_version`, `divi_essential_installed`.
3. Re-install and activate the plugin (fresh state).
4. Expected:
   - Admin dashboard redirects to license page from every DE menu item.
   - Admin banner: red "Divi Essential is locked. Activate your license."
   - Public frontend page with DE modules: modules render as blank space.
   - Logged-in admin viewing the frontend: yellow "License required" placeholder boxes.

- [ ] **Step 3: Scenario 2 — Fresh install, valid key activated**

1. From the previous state, activate a real valid license key via the license page.
2. Expected:
   - Admin banners disappear.
   - DE menus become navigable.
   - Frontend modules render normally.
   - `wp_options` row `dnext_essential_grandfather_version` = `5.5.1`; `dnext_essential_license_last_verified` ≈ `time()`.

- [ ] **Step 4: Scenarios 3 / 3a / 3b — Migration paths**

Simulate each by manually setting options then triggering `do_after_update` via admin visit:

3 (unlicensed existing):
- Set `divi_essential_version = '5.5.0'`, `dnext_essential_license_status = ''` (or delete it).
- Load any admin page.
- Expected: `dnext_essential_lock_deadline` set to time() + 30 days; banner shows countdown; plugin functional.

3a (already expired):
- Set `divi_essential_version = '5.5.0'`, `dnext_essential_license_status = 'expired'`.
- Load any admin page.
- Expected: `dnext_essential_grandfather_version` = `5.5.1`; state = GRANDFATHERED; plugin functional; no `lock_deadline` set.

3b (currently valid):
- Set `divi_essential_version = '5.5.0'`, `dnext_essential_license_status = 'valid'`.
- Load any admin page.
- Expected: `dnext_essential_grandfather_version` = `5.5.1`; state = LICENSED; no visible change.

- [ ] **Step 5: Scenario 4 — Grace deadline passes**

1. From Scenario 3 state, set `dnext_essential_lock_deadline = time() - 60` (one minute in the past).
2. Load an admin page.
3. Expected: state = LOCKED; redirect behavior kicks in; banner is red.

- [ ] **Step 6: Scenario 5 — Valid license → expires on server**

Hardest to simulate exactly without triggering the real cron. Approximate:
1. With a valid license active (`grandfather_version = 5.5.1`), manually set `dnext_essential_license_status = 'expired'`.
2. Load an admin page.
3. Expected: state = GRANDFATHERED; yellow banner; plugin still functional; WP Dashboard → Updates shows no Divi Essential update even if one is staged.

- [ ] **Step 7: Scenario 6 — Grandfathered user uploads newer ZIP**

1. With state from Scenario 5 (grandfathered at 5.5.1), edit the plugin header of `divi-essential.php` to `Version: 5.6.0` — simulating a manual ZIP upgrade past the pin.
2. Reload an admin page.
3. Expected: state = LOCKED_BYPASSED; red banner with "Version mismatch"; full lock.
4. Revert to `Version: 5.5.1` before continuing.

- [ ] **Step 8: Scenario 7 — Grandfathered user renews**

1. Continue from Scenario 5 (state = GRANDFATHERED).
2. Activate a currently-valid license key.
3. Expected: `dnext_essential_license_status = 'valid'`; `dnext_essential_grandfather_version` updated to `5.5.1`; `dnext_essential_lock_deadline` deleted; `update_plugins` transient flushed. State = LICENSED. Banners gone.

- [ ] **Step 9: Scenario 8 — Server unreachable < 14 days**

1. Point `DNEXT_ESSENTIAL_STORE_URL` to an unreachable address (hack in [`includes/Admin/Licensing.php`](../../../includes/Admin/Licensing.php) temporarily).
2. Trigger the cron manually: `wp cron event run dnext_essential_daily_license_check` (or via a plugin like WP Crontrol).
3. Expected: `dnext_essential_license_status` unchanged; `dnext_essential_last_check_error` updated; state preserved.
4. Revert the URL hack.

- [ ] **Step 10: Scenario 9 — Server unreachable > 14 days**

1. Set `dnext_essential_license_last_verified = time() - ( 15 * DAY_IN_SECONDS )`.
2. Load an admin page.
3. Expected: state = LOCKED_STALE; yellow banner specific to stale state.
4. Reset `last_verified` to `time()` before continuing.

- [ ] **Step 11: Scenario 10 — Existing page with DE modules becomes locked**

1. Install plugin + license. Create a Divi 5 page using a DE module like Text Animation. Publish.
2. Put the site into a LOCKED state (delete license key, set status = 'missing', clear lock_deadline).
3. Reload the public page as a logged-out visitor → module area is blank.
4. Log in as admin and reload → yellow placeholder visible in place of the module.
5. Open the page in Divi 5 Builder → existing DE module still selectable; DE modules do NOT appear in Add Module library.

- [ ] **Step 12: Final commit (if any adjustments were made during verification)**

If Step 1-11 revealed any issues that required code adjustments, commit the fixes. Otherwise:

```bash
git log --oneline -20
```

Expected: clean history of ~15 commits, most recent being `chore(release): bump to 5.5.1 with license-enforcement changelog`.

- [ ] **Step 13: Merge and tag**

Only after all verification steps pass:

```bash
git checkout main
git merge --no-ff feature/licensing-enforcement-5.5.1
git tag -a v5.5.1 -m "Release 5.5.1: license enforcement"
```

Do NOT push or publish until a human reviews the merge. The `git push origin main v5.5.1` step is the human's call.

---

## Risks & rollback

**Highest-risk change:** the JS `license-gate.ts` filter in Task 13. The hook name `divi.moduleLibrary.moduleList` is a best-guess and will need correction once the real Divi 5 filter hook name is confirmed. If the wrong name is used, the builder library simply doesn't hide modules — which is a soft failure (other lock mechanisms still work).

**Rollback path if a released 5.5.1 breaks customer sites:**
1. `git revert` the commits since `v5.5.0`.
2. Release 5.5.2 with just the typo fix from Task 4.
3. Investigate logs to understand the failure.

**Database rollback:** none of the new options are destructive. To revert behavior-wise:
- `delete_option('dnext_essential_lock_deadline')` — removes the migration grace, restoring pre-5.5.1 behavior for unlicensed installs.
- The `dnwoo_` typo fix is irreversible and harmless.

---

## Out-of-scope for this plan

Explicitly not addressed here (separate tasks if desired):
- Cleanup of the 200+ lines of commented-out legacy license code in [`Licensing.php:83-304`](../../../includes/Admin/Licensing.php).
- Three.js conditional enqueue optimization (prior audit).
- Multisite support (per-blog vs network-wide license storage).
- Unit tests for Cron HTTP handler (needs a WP test bootstrap — significant scope expansion).
- UI polish for the admin banners (current copy is functional but plain).
