Plugin Directory

Changeset 3784837


Ignore:
Timestamp:
11/04/2026 09:39:12 PM (6 hours ago)
Author:
dudaster
Message:

Release 1.5.0: security hardening primitives (user enumeration, security headers, WordPress version), every operation as a WordPress Ability, declared effects for every primitive

Location:
clautron
Files:
20 added
70 edited
1 copied

Legend:

Unmodified
Added
Removed
  • clautron/tags/1.5.0/clautron.php

    r3691450 r3784837  
    44 * Plugin URI:        https://wordpress.org/plugins/clautron/
    55 * Description:       Get the feature. Not the plugin. Add exactly the WordPress capability you need: inspectable, secure, reversible.
    6  * Version:           1.2.0
     6 * Version:           1.5.0
    77 * Author:            Clautron
    88 * Author URI:        https://profiles.wordpress.org/dudaster/
    … …  
    2020// ─── Constants ───────────────────────────────────────────────────────────────
    2121
    22 define( 'CLAUTRON_VERSION',     '1.2.0' );
     22define( 'CLAUTRON_VERSION',     '1.5.0' );
    2323define( 'CLAUTRON_FILE',        __FILE__ );
    2424define( 'CLAUTRON_DIR',         plugin_dir_path( __FILE__ ) );
    2525define( 'CLAUTRON_URL',         plugin_dir_url( __FILE__ ) );
    26 define( 'CLAUTRON_DB_VERSION',  '4' );
     26define( 'CLAUTRON_DB_VERSION',  '5' );
    2727
    2828// ─── Autoloader ──────────────────────────────────────────────────────────────
    … …  
    121121        'primitive-cookie'     => 'includes/primitives/class-primitive-cookie.php',
    122122
     123        // Consent (cookie-consent banner + category-gated script output)
     124        'primitive-consent'    => 'includes/primitives/class-primitive-consent.php',
     125
    123126        // Language (multilingual query filtering)
    124127        'primitive-language'   => 'includes/primitives/class-primitive-language.php',
    … …  
    157160        'catalog'              => 'includes/class-catalog.php',
    158161
     162        // Capability Manifest (machine-readable contract: version, requirements, scopes, effects)
     163        'manifest-registry'    => 'includes/registry/class-manifest-registry.php',
     164        'capability-store'     => 'includes/class-capability-store.php',
     165
    159166        // Smoke Tester (verify compiled blueprints are wired correctly)
    160167        'smoke-tester'         => 'includes/class-smoke-tester.php',
     168
     169        // Abilities (every operation above as a self-describing WordPress Ability)
     170        'abilities'            => 'includes/class-abilities.php',
    161171
    162172        // Admin
  • clautron/tags/1.5.0/demo/blueprint-cookie-consent.json

    r3691441 r3784837  
    22  "id": "f2a3b4c5-d6e7-4890-b123-c45678901bcd",
    33  "object_type": "feature",
    4   "name": "Cookie Consent Banner",
    5   "description": "Shows a GDPR-style cookie consent banner in the footer with an Accept link. Clicking it stores consent in a first-party cookie for 365 days, which also suppresses the banner on return visits. Replaces a basic cookie-notice plugin.",
     4  "name": "GDPR Consent Banner",
     5  "description": "Cookie-consent banner with per-category consent (necessary/analytics/marketing), a full audit trail of who consented to what and when, automatic opt-out when the visitor's browser sends Global Privacy Control (Sec-GPC), and Google Consent Mode v2 signals for GA4/Ads. Replaces a dedicated cookie-consent plugin for the common case.",
    66  "capability_id": "cookie-consent-banner",
    77  "provides": {},
    88  "status": "draft",
    99  "source": "manual",
    10   "nl_input": "Show a cookie consent banner on the site with an accept button that remembers the choice for a year.",
     10  "nl_input": "Show a GDPR cookie consent banner with analytics/marketing categories, honor Global Privacy Control automatically, and signal Google Consent Mode.",
    1111  "structures": [
    1212    {
    1313      "id": "s-banner",
    14       "type": "inject_wp_footer",
     14      "type": "consent_banner",
    1515      "config": {
    16         "template": "<div id=\"clautron-cookie-banner\" style=\"position:fixed;bottom:0;left:0;right:0;background:#222;color:#fff;padding:14px 20px;font-size:14px;z-index:9999;\">This site uses cookies to improve your experience. <a href=\"?clautron_action=accept_cookies\" style=\"color:#8ecdf7;\">Accept</a></div>",
    17         "suppress_if_cookie": "cookie_consent"
     16        "categories": [
     17          { "key": "necessary", "label": "Necessary", "description": "Required for the site to function.", "required": true },
     18          { "key": "analytics", "label": "Analytics", "description": "Helps us understand site usage." },
     19          { "key": "marketing", "label": "Marketing", "description": "Used for ad personalization." }
     20        ],
     21        "policy_version": "1",
     22        "banner_text": "This site uses cookies. Choose which categories you're OK with.",
     23        "position": "bottom",
     24        "days": 180,
     25        "endpoint_path": "consent",
     26        "honor_gpc": true,
     27        "consent_mode_map": {
     28          "analytics": ["analytics_storage"],
     29          "marketing": ["ad_storage", "ad_user_data", "ad_personalization"]
     30        }
     31      }
     32    },
     33    {
     34      "id": "s-analytics-script",
     35      "type": "gate_script_by_consent",
     36      "config": {
     37        "category": "analytics",
     38        "html": "<!-- Load your analytics script here — it only outputs once \"analytics\" consent is granted -->",
     39        "placement": "head"
    1840      }
    1941    }
    20   ],
    21   "trigger": { "type": "hook", "config": { "hook": "template_redirect" } },
    22   "conditions": [
    23     { "id": "c1", "type": "read_request_param", "config": { "source": "get", "param": "clautron_action", "output_variable": "action", "default": "" } },
    24     { "id": "c2", "type": "if_else", "config": { "field": "{{action}}", "operator": "eq", "value": "accept_cookies", "pass_branch": ["a1"], "fail_branch": [] } }
    25   ],
    26   "actions": [
    27     { "id": "a1", "type": "write_cookie", "config": { "name": "cookie_consent", "value": "accepted", "days": 365 } }
    2842  ]
    2943}
  • clautron/tags/1.5.0/includes/class-catalog.php

    r3582869 r3784837  
    99 *  - Active capabilities (compiled + running on this site).
    1010 *  - Available templates (demo blueprints not yet installed).
     11 *  - inspect(): install/version/compatibility state for one capability_id.
     12 *
     13 * When a capability_id has a manifest (demo/capabilities/*.manifest.json,
     14 * loaded via CLAUTRON_Manifest_Registry), catalog entries carry it under
     15 * 'manifest' plus a 'has_manifest' flag — additive, so capabilities without
     16 * a manifest keep working exactly as before.
    1117 *
    1218 * External systems (e.g. AICOM) call these methods directly — no HTTP needed.
    … …  
    3339                continue;
    3440            }
     41            $manifest = class_exists( 'CLAUTRON_Manifest_Registry' ) ? CLAUTRON_Manifest_Registry::get( $data['capability_id'] ) : null;
     42
    3543            $active[] = [
    3644                'capability_id' => $data['capability_id'],
    … …  
    4250                'provides'      => $data['provides'] ?? [],
    4351                'llm_guide'     => $data['llm_guide'] ?? '',
     52                'manifest'      => $manifest,
     53                'has_manifest'  => $manifest !== null,
    4454            ];
    4555        }
    … …  
    7484                continue;
    7585            }
     86            $manifest = class_exists( 'CLAUTRON_Manifest_Registry' ) ? CLAUTRON_Manifest_Registry::get( $data['capability_id'] ) : null;
     87
    7688            $templates[] = [
    7789                'capability_id' => $data['capability_id'],
    … …  
    8395                'provides'      => $data['provides'] ?? [],
    8496                'llm_guide'     => $data['llm_guide'] ?? '',
     97                'manifest'      => $manifest,
     98                'has_manifest'  => $manifest !== null,
    8599            ];
    86100        }
    … …  
    127141        }
    128142
    129         // Find template file.
    130         $demo_dir = CLAUTRON_DIR . 'demo/';
    131         $template = null;
    132         foreach ( glob( $demo_dir . '*.json' ) ?: [] as $path ) {
    133             $data = json_decode( file_get_contents( $path ), true );
    134             if ( is_array( $data ) && ( $data['capability_id'] ?? '' ) === $capability_id ) {
    135                 $template = $data;
    136                 break;
    137             }
    138         }
     143        // Known but not active (e.g. deactivated) — reactivate the SAME
     144        // blueprint instead of creating a new one, so version history and
     145        // the blueprint's own identity survive a deactivate → install cycle.
     146        $known = class_exists( 'CLAUTRON_Capability_Store' )
     147            ? CLAUTRON_Capability_Store::find_by_capability_id( $capability_id )
     148            : null;
     149        if ( $known ) {
     150            $existing_blueprint_id = (int) $known->blueprint_id;
     151            $existing_row          = CLAUTRON_Blueprint_Store::get( $existing_blueprint_id );
     152            if ( $existing_row ) {
     153                $result = CLAUTRON_Plugin::make_compiler()->compile( $existing_blueprint_id );
     154                if ( ! empty( $result->get_errors() ) ) {
     155                    return [
     156                        'success'       => false,
     157                        'blueprint_id'  => $existing_blueprint_id,
     158                        'compiled'      => false,
     159                        'capability_id' => $capability_id,
     160                        'error'         => implode( '; ', $result->get_errors() ),
     161                    ];
     162                }
     163                self::record_installed_instance( $capability_id, $existing_blueprint_id );
     164                return [
     165                    'success'       => true,
     166                    'blueprint_id'  => $existing_blueprint_id,
     167                    'compiled'      => true,
     168                    'capability_id' => $capability_id,
     169                    'note'          => 'Reactivated existing blueprint.',
     170                    'warnings'      => $result->get_warnings(),
     171                ];
     172            }
     173            // Fall through to a fresh template install if the known
     174            // blueprint row itself is gone (shouldn't normally happen).
     175        }
     176
     177        $template = self::find_template_data( $capability_id );
    139178
    140179        if ( ! $template ) {
    … …  
    178217        }
    179218
     219        self::record_installed_instance( $capability_id, $blueprint_id );
     220
    180221        return [
    181222            'success'       => true,
    … …  
    187228    }
    188229
     230    /**
     231     * Update an already-installed capability to its manifest's current
     232     * declared version, in place — same blueprint row, new blueprint_json,
     233     * recompiled. Distinct from install(): requires the capability to
     234     * already be installed, and requires a manifest (the target version
     235     * always comes from the manifest's own 'version' field — there's no
     236     * store of multiple historical template versions to pick from).
     237     *
     238     * @return array {
     239     *   @type bool        $success
     240     *   @type string      $status         'already_current' | 'updated' (only on success)
     241     *   @type int|null    $blueprint_id
     242     *   @type string|null $from_version
     243     *   @type string|null $to_version
     244     *   @type string      $error          (only on failure)
     245     * }
     246     */
     247    public static function update( string $capability_id ): array {
     248        $installed = class_exists( 'CLAUTRON_Capability_Store' )
     249            ? CLAUTRON_Capability_Store::find_by_capability_id( $capability_id )
     250            : null;
     251
     252        if ( ! $installed ) {
     253            return [
     254                'success' => false,
     255                'error'   => "Capability '{$capability_id}' is not installed. Use clautron.catalog.install first.",
     256                'code'    => 'not_installed',
     257            ];
     258        }
     259
     260        $manifest = class_exists( 'CLAUTRON_Manifest_Registry' ) ? CLAUTRON_Manifest_Registry::get( $capability_id ) : null;
     261        if ( ! $manifest ) {
     262            return [
     263                'success' => false,
     264                'error'   => "Capability '{$capability_id}' has no manifest, so it has no declared target version to update to.",
     265                'code'    => 'no_manifest',
     266            ];
     267        }
     268
     269        $from_version = $installed->installed_version;
     270        $to_version   = $manifest['version'];
     271
     272        if ( $from_version === $to_version ) {
     273            return [
     274                'success'      => true,
     275                'status'       => 'already_current',
     276                'blueprint_id' => (int) $installed->blueprint_id,
     277                'from_version' => $from_version,
     278                'to_version'   => $to_version,
     279            ];
     280        }
     281
     282        $template = self::find_template_data( $capability_id );
     283        if ( ! $template ) {
     284            return [
     285                'success' => false,
     286                'error'   => "No template found for capability_id '{$capability_id}'.",
     287                'code'    => 'template_missing',
     288            ];
     289        }
     290
     291        $blueprint_id = (int) $installed->blueprint_id;
     292        $blueprint_row = CLAUTRON_Blueprint_Store::get( $blueprint_id );
     293        if ( ! $blueprint_row ) {
     294            return [
     295                'success' => false,
     296                'error'   => "Installed blueprint (id {$blueprint_id}) no longer exists.",
     297                'code'    => 'blueprint_missing',
     298            ];
     299        }
     300
     301        // In-place edit: keep the existing blueprint's own UUID — this
     302        // updates the same blueprint row, it doesn't create a new one.
     303        $template['id'] = $blueprint_row->uuid;
     304
     305        $validation_blueprint = new CLAUTRON_Blueprint( $template );
     306        $validation           = CLAUTRON_Blueprint_Validator::validate( $validation_blueprint );
     307        if ( ! $validation->is_valid() ) {
     308            return [
     309                'success' => false,
     310                'error'   => 'Updated template failed validation: ' . implode( '; ', $validation->get_errors() ),
     311                'code'    => 'validation_failed',
     312            ];
     313        }
     314
     315        $encoded = wp_json_encode( $template );
     316        $saved   = CLAUTRON_Blueprint_Store::update_json( $blueprint_id, $encoded, "Updated to v{$to_version} via Capability Contract" );
     317        if ( ! $saved ) {
     318            return [
     319                'success' => false,
     320                'error'   => 'Failed to save updated blueprint to database.',
     321                'code'    => 'save_failed',
     322            ];
     323        }
     324
     325        $result = CLAUTRON_Plugin::make_compiler()->compile( $blueprint_id );
     326        if ( ! empty( $result->get_errors() ) ) {
     327            return [
     328                'success'      => false,
     329                'blueprint_id' => $blueprint_id,
     330                'from_version' => $from_version,
     331                'to_version'   => $to_version,
     332                'error'        => implode( '; ', $result->get_errors() ),
     333                'code'         => 'compile_failed',
     334            ];
     335        }
     336
     337        self::record_installed_instance( $capability_id, $blueprint_id );
     338
     339        return [
     340            'success'      => true,
     341            'status'       => 'updated',
     342            'blueprint_id' => $blueprint_id,
     343            'from_version' => $from_version,
     344            'to_version'   => $to_version,
     345            'warnings'     => $result->get_warnings(),
     346        ];
     347    }
     348
     349    /**
     350     * Deactivate an installed capability — stops its compile-phase
     351     * primitives from being re-applied on the next request
     352     * (CLAUTRON_Runtime_Engine::boot() only replays workflows with
     353     * status='active'; CLAUTRON_Compiler::decompile() removes the
     354     * workflow row and pauses the blueprint). Never touches data: no
     355     * postmeta, terms, or custom table rows written while the capability
     356     * was active are read or modified. Always allowed, regardless of
     357     * reversibility.strategy — that field describes what a future
     358     * `rollback` action would (not) be able to undo, it does not gate
     359     * deactivate.
     360     *
     361     * @return array {
     362     *   @type bool     $success
     363     *   @type string   $status           'deactivated' (only on success)
     364     *   @type int|null $blueprint_id
     365     *   @type bool     $data_preserved   Always true on success.
     366     *   @type string[] $residual_effects Human-readable notes derived from the manifest's 'effects', if any.
     367     *   @type string   $error            (only on failure)
     368     * }
     369     */
     370    public static function deactivate( string $capability_id ): array {
     371        $installed = class_exists( 'CLAUTRON_Capability_Store' )
     372            ? CLAUTRON_Capability_Store::find_by_capability_id( $capability_id )
     373            : null;
     374
     375        if ( ! $installed || $installed->status !== 'active' ) {
     376            return [
     377                'success' => false,
     378                'error'   => "Capability '{$capability_id}' is not currently active.",
     379                'code'    => 'not_active',
     380            ];
     381        }
     382
     383        $blueprint_id = (int) $installed->blueprint_id;
     384
     385        CLAUTRON_Plugin::make_compiler()->decompile( $blueprint_id );
     386
     387        // Covers decompile()'s one confirmed gap: it doesn't flush rewrite
     388        // rules, so a register_cpt/register_taxonomy with a custom slug
     389        // can leave stale permalinks until the next flush. Cheap enough
     390        // for a rare, explicit operation.
     391        flush_rewrite_rules( false );
     392
     393        CLAUTRON_Capability_Store::update_status( $capability_id, 'paused' );
     394
     395        $manifest          = class_exists( 'CLAUTRON_Manifest_Registry' ) ? CLAUTRON_Manifest_Registry::get( $capability_id ) : null;
     396        $residual_effects  = self::describe_residual_effects( $manifest['effects'] ?? [] );
     397
     398        return [
     399            'success'          => true,
     400            'status'           => 'deactivated',
     401            'blueprint_id'     => $blueprint_id,
     402            'data_preserved'   => true,
     403            'residual_effects' => $residual_effects,
     404        ];
     405    }
     406
     407    /**
     408     * Translate a manifest's declared 'effects' into human-readable notes
     409     * about what deactivate does NOT undo — derived from data already
     410     * declared on the manifest, no new metadata needed.
     411     */
     412    private static function describe_residual_effects( array $effects ): array {
     413        $notes = [];
     414        if ( ! empty( $effects['writes_database'] ) ) {
     415            $notes[] = 'Data written while active (e.g. post meta) is preserved and not reverted.';
     416        }
     417        if ( ! empty( $effects['changes_schema'] ) ) {
     418            $notes[] = 'Any schema changes made while active (e.g. new fields/columns) are preserved.';
     419        }
     420        if ( ! empty( $effects['writes_files'] ) ) {
     421            $notes[] = 'Files written while active are preserved on disk.';
     422        }
     423        return $notes;
     424    }
     425
     426    /**
     427     * Locate a demo template's decoded JSON by capability_id, or null.
     428     */
     429    private static function find_template_data( string $capability_id ): ?array {
     430        $demo_dir = CLAUTRON_DIR . 'demo/';
     431        foreach ( glob( $demo_dir . '*.json' ) ?: [] as $path ) {
     432            $data = json_decode( file_get_contents( $path ), true );
     433            if ( is_array( $data ) && ( $data['capability_id'] ?? '' ) === $capability_id ) {
     434                return $data;
     435            }
     436        }
     437        return null;
     438    }
     439
     440    /**
     441     * Upsert the installed-instance row (wp_clautron_capabilities) after a
     442     * successful install/compile — separate from the generic blueprint row,
     443     * see CLAUTRON_Capability_Store.
     444     */
     445    private static function record_installed_instance( string $capability_id, int $blueprint_id ): void {
     446        if ( ! class_exists( 'CLAUTRON_Capability_Store' ) ) {
     447            return;
     448        }
     449
     450        $manifest = class_exists( 'CLAUTRON_Manifest_Registry' ) ? CLAUTRON_Manifest_Registry::get( $capability_id ) : null;
     451
     452        CLAUTRON_Capability_Store::upsert( [
     453            'capability_id'     => $capability_id,
     454            'blueprint_id'      => $blueprint_id,
     455            'installed_version' => $manifest['version'] ?? 'unknown',
     456            'status'            => 'active',
     457            'reversibility'     => $manifest['reversibility']['strategy'] ?? 'none',
     458        ] );
     459    }
     460
    189461    // ── Idempotent provisioning ───────────────────────────────────────────────
    190462
    191463    /**
    192      * Ensure a capability exists and is active. Idempotent — safe to call repeatedly.
     464     * What ensure() would do for a capability_id, without doing it. ensure()
     465     * itself follows this, so a dry run can never disagree with the real call.
    193466     *
    194467     * Resolution order:
    195      *  1. Already active with this capability_id → return it unchanged.
    196      *  2. Available as a demo template → install it.
    197      *  3. blueprint_json provided → create + compile (capability_id is forced into the JSON).
     468     *  1. already_active   — active with this capability_id: nothing to do.
     469     *  2. reactivate       — known but deactivated, blueprint still there:
     470     *                        install() recompiles the SAME blueprint. Without
     471     *                        this a custom (non-template) capability would get
     472     *                        a second blueprint every time it was ensured
     473     *                        after a deactivate.
     474     *  3. install_template — a demo template: install it.
     475     *  4. create           — create + compile the caller's blueprint_json.
     476     *
     477     * @return array{action: string, blueprint_id: ?int}
     478     */
     479    public static function ensure_plan( string $capability_id ): array {
     480        $cap = self::find_active( $capability_id );
     481        if ( $cap ) {
     482            return [ 'action' => 'already_active', 'blueprint_id' => (int) $cap['blueprint_id'] ];
     483        }
     484
     485        $known = CLAUTRON_Capability_Store::find_by_capability_id( $capability_id );
     486        if ( $known && CLAUTRON_Blueprint_Store::get( (int) $known->blueprint_id ) ) {
     487            return [ 'action' => 'reactivate', 'blueprint_id' => (int) $known->blueprint_id ];
     488        }
     489
     490        if ( in_array( $capability_id, array_column( self::get_templates(), 'capability_id' ), true ) ) {
     491            return [ 'action' => 'install_template', 'blueprint_id' => null ];
     492        }
     493
     494        return [ 'action' => 'create', 'blueprint_id' => null ];
     495    }
     496
     497    /**
     498     * Ensure a capability exists and is active. Idempotent — safe to call
     499     * repeatedly. Resolution order: see ensure_plan().
    198500     *
    199501     * @param string $capability_id  Unique slug identifying this capability.
    … …  
    201503     * @return array {
    202504     *   @type bool   $success
    203      *   @type string $status        'already_active' | 'installed_from_template' | 'created_and_compiled'
     505     *   @type string $status        'already_active' | 'reactivated' | 'installed_from_template' | 'created_and_compiled'
    204506     *   @type string $capability_id
    205507     *   @type int    $blueprint_id
    … …  
    208510     */
    209511    public static function ensure( string $capability_id, string $blueprint_json = '' ): array {
    210         // ── 1. Already active ─────────────────────────────────────────────────
    211         $cap = self::find_active( $capability_id );
    212         if ( $cap ) {
     512        $plan = self::ensure_plan( $capability_id );
     513
     514        if ( 'already_active' === $plan['action'] ) {
    213515            return [
    214516                'success'       => true,
    215517                'status'        => 'already_active',
    216518                'capability_id' => $capability_id,
    217                 'blueprint_id'  => $cap['blueprint_id'],
    218             ];
    219         }
    220 
    221         // ── 2. Available as template ──────────────────────────────────────────
    222         $templates = self::get_templates();
    223         foreach ( $templates as $tpl ) {
    224             if ( $tpl['capability_id'] === $capability_id ) {
    225                 $result           = self::install( $capability_id );
    226                 $result['status'] = $result['success'] ? 'installed_from_template' : ( $result['status'] ?? 'error' );
    227                 return $result;
    228             }
    229         }
    230 
    231         // ── 3. Create from provided blueprint_json ────────────────────────────
     519                'blueprint_id'  => $plan['blueprint_id'],
     520            ];
     521        }
     522
     523        if ( 'reactivate' === $plan['action'] || 'install_template' === $plan['action'] ) {
     524            $result           = self::install( $capability_id );
     525            $result['status'] = $result['success']
     526                ? ( 'reactivate' === $plan['action'] ? 'reactivated' : 'installed_from_template' )
     527                : ( $result['status'] ?? 'error' );
     528            return $result;
     529        }
     530
     531        // ── create: from the provided blueprint_json ──────────────────────────
    232532        if ( ! $blueprint_json ) {
    233533            return [
    … …  
    284584        }
    285585
     586        self::record_installed_instance( $capability_id, $blueprint_id );
     587
    286588        return [
    287589            'success'       => true,
    … …  
    366668    }
    367669
     670    // ── Inspect ───────────────────────────────────────────────────────────────
     671
     672    /**
     673     * Contract-level actions that always work for any capability_id,
     674     * manifest or not — they're generic lookups, never gated by what a
     675     * specific manifest opts into declaring.
     676     */
     677    private const ALWAYS_AVAILABLE_ACTIONS = [ 'discover', 'inspect' ];
     678
     679    /**
     680     * Per-capability lifecycle actions the Capability Contract currently
     681     * has a real, working executor for. A manifest may *declare* more
     682     * (e.g. "update", "verify", "remove") than the contract can actually
     683     * run yet — available_actions only ever advertises the intersection,
     684     * so a caller never gets pointed at an action that would just fail.
     685     */
     686    private const IMPLEMENTED_LIFECYCLE_ACTIONS = [ 'install', 'update', 'deactivate' ];
     687
     688    /**
     689     * Resolve current install/version/compatibility state for a capability_id.
     690     *
     691     * @return array {
     692     *   @type bool        $installed
     693     *   @type string|null $installed_version
     694     *   @type string|null $available_version   From the manifest, if one exists.
     695     *   @type bool        $compatible           True if no unmet requirements (or no manifest to check).
     696     *   @type string      $status               not_installed|active|paused|removed
     697     *   @type string[]    $available_actions    ALWAYS_AVAILABLE_ACTIONS + (manifest actions ∩ IMPLEMENTED_LIFECYCLE_ACTIONS).
     698     *   @type string[]    $unmet_requirements
     699     * }
     700     */
     701    public static function inspect( string $capability_id ): array {
     702        $manifest = class_exists( 'CLAUTRON_Manifest_Registry' ) ? CLAUTRON_Manifest_Registry::get( $capability_id ) : null;
     703
     704        $installed_row = class_exists( 'CLAUTRON_Capability_Store' )
     705            ? CLAUTRON_Capability_Store::find_by_capability_id( $capability_id )
     706            : null;
     707
     708        $unmet = ( $manifest && class_exists( 'CLAUTRON_Manifest_Registry' ) )
     709            ? CLAUTRON_Manifest_Registry::check_requirements( $manifest )
     710            : [];
     711
     712        $declared_actions  = $manifest['actions'] ?? [];
     713        $available_actions = array_values( array_unique( array_merge(
     714            self::ALWAYS_AVAILABLE_ACTIONS,
     715            array_intersect( $declared_actions, self::IMPLEMENTED_LIFECYCLE_ACTIONS )
     716        ) ) );
     717
     718        return [
     719            'installed'          => $installed_row !== null,
     720            'installed_version'  => $installed_row->installed_version ?? null,
     721            'available_version'  => $manifest['version'] ?? null,
     722            'compatible'         => empty( $unmet ),
     723            'status'             => $installed_row->status ?? 'not_installed',
     724            'available_actions'  => $available_actions,
     725            'unmet_requirements' => $unmet,
     726        ];
     727    }
     728
    368729    // ── Helpers ───────────────────────────────────────────────────────────────
    369730
  • clautron/tags/1.5.0/includes/class-db.php

    r3582869 r3784837  
    1414 *   3 — add last_error column to jobs table
    1515 *   4 — add clautron_events time-series table
     16 *   5 — add clautron_capabilities table (installed-instance state, separate
     17 *       from the source/template manifest — see docs/schemas/capability_manifest.json)
    1618 */
    1719class CLAUTRON_DB {
    … …  
    193195        ) $charset;" );
    194196
     197        // ── capabilities ──────────────────────────────────────────────────────
     198        // Installed-instance state for a capability, separate from the
     199        // source/template manifest (demo/capabilities/*.manifest.json) and
     200        // from the generic blueprints table, which serves every blueprint
     201        // whether or not it's a governed capability. One row per capability_id
     202        // currently installed on this site.
     203        dbDelta( "CREATE TABLE {$pfx}capabilities (
     204            id                 BIGINT UNSIGNED  NOT NULL AUTO_INCREMENT,
     205            capability_id      VARCHAR(191)     NOT NULL,
     206            blueprint_id       BIGINT UNSIGNED  NOT NULL,
     207            installed_version  VARCHAR(20)      NOT NULL DEFAULT 'unknown',
     208            status             VARCHAR(20)      NOT NULL DEFAULT 'active',
     209            reversibility      VARCHAR(10)      NOT NULL DEFAULT 'none',
     210            installed_at       DATETIME         NOT NULL,
     211            updated_at         DATETIME         NOT NULL,
     212            PRIMARY KEY  (id),
     213            UNIQUE KEY   capability_id (capability_id),
     214            KEY          blueprint_id (blueprint_id)
     215        ) $charset;" );
     216
    195217        // ── In-place migrations for existing installs ─────────────────────────
    196218        // dbDelta reliably creates tables but does NOT add missing columns.
    … …  
    203225        }
    204226
     227        // v5: backfill capabilities table for blueprints that were installed
     228        // (compiled + active, with a capability_id) before this table existed.
     229        // Preserves continuity — nothing about an already-installed capability
     230        // changes on upgrade, it just becomes visible to the new inspect()/
     231        // versioning machinery.
     232        self::backfill_capabilities();
     233
    205234        update_option( 'clautron_db_version', CLAUTRON_DB_VERSION );
     235    }
     236
     237    /**
     238     * One-time backfill: for every active blueprint that already declares a
     239     * capability_id but has no row yet in the capabilities table, insert one.
     240     * Safe to call repeatedly — skips capability_ids that already have a row.
     241     */
     242    private static function backfill_capabilities(): void {
     243        global $wpdb;
     244
     245        $existing_ids = $wpdb->get_col( $wpdb->prepare( 'SELECT capability_id FROM %i', self::capabilities() ) );
     246
     247        $rows = $wpdb->get_results( $wpdb->prepare(
     248            'SELECT id, blueprint_json FROM %i WHERE status = %s',
     249            self::blueprints(),
     250            'active'
     251        ) );
     252
     253        $now = current_time( 'mysql', true );
     254
     255        foreach ( $rows as $row ) {
     256            $data = json_decode( $row->blueprint_json, true );
     257            if ( ! is_array( $data ) || empty( $data['capability_id'] ) ) {
     258                continue;
     259            }
     260            if ( in_array( $data['capability_id'], $existing_ids, true ) ) {
     261                continue;
     262            }
     263
     264            $manifest          = class_exists( 'CLAUTRON_Manifest_Registry' ) ? CLAUTRON_Manifest_Registry::get( $data['capability_id'] ) : null;
     265            $installed_version = $manifest['version'] ?? 'unknown';
     266            $reversibility     = $manifest['reversibility']['strategy'] ?? 'none';
     267
     268            $wpdb->insert(
     269                self::capabilities(),
     270                [
     271                    'capability_id'     => $data['capability_id'],
     272                    'blueprint_id'      => (int) $row->id,
     273                    'installed_version' => $installed_version,
     274                    'status'            => 'active',
     275                    'reversibility'     => $reversibility,
     276                    'installed_at'      => $now,
     277                    'updated_at'        => $now,
     278                ],
     279                [ '%s', '%d', '%s', '%s', '%s', '%s', '%s' ]
     280            );
     281        }
    206282    }
    207283
    … …  
    212288        global $wpdb;
    213289        $pfx    = $wpdb->prefix . 'clautron_';
    214         $tables = [ 'events', 'jobs', 'ai_requests', 'conversations', 'execution_logs', 'workflows', 'versions', 'blueprints' ];
     290        $tables = [ 'capabilities', 'events', 'jobs', 'ai_requests', 'conversations', 'execution_logs', 'workflows', 'versions', 'blueprints' ];
    215291        foreach ( $tables as $t ) {
    216292            $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $pfx . $t ) );
    … …  
    229305    public static function jobs(): string          { global $wpdb; return $wpdb->prefix . 'clautron_jobs'; }
    230306    public static function events(): string        { global $wpdb; return $wpdb->prefix . 'clautron_events'; }
     307    public static function capabilities(): string  { global $wpdb; return $wpdb->prefix . 'clautron_capabilities'; }
    231308}
  • clautron/tags/1.5.0/includes/class-plugin.php

    r3691441 r3784837  
    4242        CLAUTRON_Primitive_Redirect::register_all();
    4343        CLAUTRON_Primitive_Cookie::register_all();
     44        CLAUTRON_Primitive_Consent::register_all();
    4445        CLAUTRON_Primitive_Language::register_all();
    4546        CLAUTRON_Primitive_Request::register_all();
    … …  
    5960        // ── 3. Boot job runner ───────────────────────────────────────────────
    6061        CLAUTRON_Job_Runner::boot();
     62
     63        // ── 3b. Expose operations as WordPress Abilities ─────────────────────
     64        CLAUTRON_Abilities::boot();
    6165
    6266        // ── 4b. Boot runtime engine ──────────────────────────────────────────
  • clautron/tags/1.5.0/includes/class-smoke-tester.php

    r3582869 r3784837  
    55 * Smoke Tester — verifies that a compiled blueprint is correctly wired into WordPress.
    66 *
    7  * Checks are lightweight WordPress-introspection only (no HTTP requests).
    8  * Each check inspects the WordPress state that the compile-phase primitives
    9  * should have set up: post types, taxonomies, meta boxes, columns, hooks, etc.
     7 * Generic executor: each compile-phase structure node is checked by its own
     8 * primitive's declared `verify` callable (CLAUTRON_Primitive_Registry). A
     9 * primitive type with no `verify` declared lands in `unavailable`, not
     10 * silently dropped — see the primitive's own file for its check, and
     11 * CLAUTRON_Primitive_Registry::register()'s docblock for the `verify`
     12 * contract. Part of the AICOM-Clautron Capability Contract v1.
    1013 */
    1114class CLAUTRON_Smoke_Tester {
    … …  
    2023     *   @type string[] $failed
    2124     *   @type string[] $warnings
     25     *   @type string[] $unavailable  Structures whose primitive type has no declared verify check.
    2226     *   @type string   $summary
    2327     * }
    … …  
    2630        $row = CLAUTRON_Blueprint_Store::get( $blueprint_id );
    2731        if ( ! $row ) {
    28             return self::result( [], [], [ "Blueprint {$blueprint_id} not found." ], 0, 0 );
     32            return self::result( [], [], [ "Blueprint {$blueprint_id} not found." ], [] );
    2933        }
    3034
    3135        $data = json_decode( $row->blueprint_json, true );
    3236        if ( ! is_array( $data ) ) {
    33             return self::result( [], [], [ 'blueprint_json is not valid JSON.' ], 0, 0 );
     37            return self::result( [], [], [ 'blueprint_json is not valid JSON.' ], [] );
    3438        }
    3539
    3640        if ( $row->status !== 'active' ) {
    37             return self::result( [], [], [ "Blueprint is '{$row->status}', not active — compile it first." ], 0, 0 );
     41            return self::result( [], [], [ "Blueprint is '{$row->status}', not active — compile it first." ], [] );
    3842        }
    3943
    40         $passed   = [];
    41         $failed   = [];
    42         $warnings = [];
     44        $passed      = [];
     45        $failed      = [];
     46        $warnings    = [];
     47        $unavailable = [];
    4348
    4449        // ── Check trigger ──────────────────────────────────────────────────────
     50        // Not a primitive — the blueprint's own trigger field, checked directly.
    4551        $trigger = $data['trigger'] ?? null;
    4652        if ( $trigger ) {
    … …  
    5965            $id     = $node['id'] ?? $type;
    6066
    61             switch ( $type ) {
     67            $def = class_exists( 'CLAUTRON_Primitive_Registry' ) ? CLAUTRON_Primitive_Registry::get( $type ) : null;
    6268
    63                 case 'register_cpt': {
    64                     $pt = $config['post_type'] ?? '';
    65                     if ( $pt && get_post_type_object( $pt ) ) {
    66                         $passed[] = "register_cpt:{$pt}";
    67                     } elseif ( $pt ) {
    68                         $failed[] = "register_cpt:{$pt} — post type not registered.";
    69                     }
    70                     break;
    71                 }
     69            if ( ! $def ) {
     70                $unavailable[] = "{$type}:{$id} — primitive type not registered.";
     71                continue;
     72            }
    7273
    73                 case 'register_taxonomy': {
    74                     $tax = $config['taxonomy'] ?? '';
    75                     if ( $tax && taxonomy_exists( $tax ) ) {
    76                         $passed[] = "register_taxonomy:{$tax}";
    77                     } elseif ( $tax ) {
    78                         $failed[] = "register_taxonomy:{$tax} — taxonomy not registered.";
    79                     }
    80                     break;
    81                 }
     74            if ( empty( $def['verify'] ) || ! is_callable( $def['verify'] ) ) {
     75                $unavailable[] = "{$type}:{$id} — no verification declared for this primitive type.";
     76                continue;
     77            }
    8278
    83                 case 'register_meta': {
    84                     $key = $config['meta_key'] ?? '';
    85                     $pts = (array) ( $config['post_types'] ?? [ 'post' ] );
    86                     if ( $key ) {
    87                         $registered = registered_meta_key_exists( 'post', $key, $pts[0] ?? '' );
    88                         if ( $registered ) {
    89                             $passed[] = "register_meta:{$key}";
    90                         } else {
    91                             $warnings[] = "register_meta:{$key} — not found in registered meta (may still work via update_post_meta).";
    92                         }
    93                     }
    94                     break;
    95                 }
     79            $items = call_user_func( $def['verify'], $config );
     80            foreach ( (array) $items as $item ) {
     81                $label  = $item['label']  ?? "{$type}:{$id}";
     82                $reason = $item['reason'] ?? null;
     83                $line   = $reason ? "{$label} — {$reason}" : $label;
    9684
    97                 case 'add_metabox': {
    98                     $mbid = $config['id'] ?? '';
    99                     $pts  = (array) ( $config['post_types'] ?? [ 'post' ] );
    100                     $pt   = $pts[0] ?? 'post';
    101                     // Meta boxes are only registered in admin context.
    102                     if ( ! is_admin() ) {
    103                         $warnings[] = "add_metabox:{$mbid} — skipped (requires admin context; will work correctly on post edit screens).";
     85                switch ( $item['status'] ?? 'warning' ) {
     86                    case 'passed':
     87                        $passed[] = $line;
    10488                        break;
    105                     }
    106                     $hook_name = 'add_meta_boxes_' . $pt;
    107                     do_action( $hook_name, null );
    108                     do_action( 'add_meta_boxes', $pt, null );
    109                     global $wp_meta_boxes;
    110                     $found = false;
    111                     foreach ( $wp_meta_boxes[ $pt ] ?? [] as $context ) {
    112                         foreach ( $context as $priority ) {
    113                             if ( isset( $priority[ $mbid ] ) ) {
    114                                 $found = true;
    115                                 break 2;
    116                             }
    117                         }
    118                     }
    119                     if ( $found ) {
    120                         $passed[] = "add_metabox:{$mbid}";
    121                     } else {
    122                         $failed[] = "add_metabox:{$mbid} — not found in wp_meta_boxes for post type '{$pt}'.";
    123                     }
    124                     break;
    125                 }
    126 
    127                 case 'add_admin_column': {
    128                     $pts = (array) ( $config['post_types'] ?? [] );
    129                     foreach ( $pts as $pt ) {
    130                         $filter = "manage_{$pt}_posts_columns";
    131                         if ( has_filter( $filter ) ) {
    132                             $passed[] = "add_admin_column:{$pt}";
    133                         } else {
    134                             $failed[] = "add_admin_column:{$pt} — filter '{$filter}' not hooked.";
    135                         }
    136                     }
    137                     break;
    138                 }
    139 
    140                 case 'inject_wp_head': {
    141                     $priority = (int) ( $config['priority'] ?? 5 );
    142                     if ( has_action( 'wp_head' ) ) {
    143                         $passed[] = "inject_wp_head (priority:{$priority})";
    144                     } else {
    145                         $failed[] = 'inject_wp_head — no actions registered on wp_head.';
    146                     }
    147                     break;
    148                 }
    149 
    150                 case 'inject_wp_footer': {
    151                     if ( has_action( 'wp_footer' ) ) {
    152                         $passed[] = 'inject_wp_footer';
    153                     } else {
    154                         $failed[] = 'inject_wp_footer — no actions registered on wp_footer.';
    155                     }
    156                     break;
    157                 }
    158 
    159                 case 'register_rest_route': {
    160                     $route = $config['route'] ?? '';
    161                     if ( $route ) {
    162                         $routes    = rest_get_server()->get_routes();
    163                         $full_path = '/clautron/v1/' . ltrim( $route, '/' );
    164                         if ( isset( $routes[ $full_path ] ) ) {
    165                             $passed[] = "register_rest_route:{$route}";
    166                         } else {
    167                             $warnings[] = "register_rest_route:{$route} — not found in registered routes (REST server may not be initialized yet).";
    168                         }
    169                     }
    170                     break;
    171                 }
    172 
    173                 case 'add_row_action': {
    174                     $pts = (array) ( $config['post_types'] ?? [] );
    175                     foreach ( $pts as $pt ) {
    176                         $filter = "post_row_actions";
    177                         if ( has_filter( $filter ) || has_filter( "{$pt}_row_actions" ) ) {
    178                             $passed[] = "add_row_action:{$pt}";
    179                         } else {
    180                             $warnings[] = "add_row_action:{$pt} — row action filter not detected (may still work).";
    181                         }
    182                     }
    183                     break;
    184                 }
    185 
    186                 case 'gate_publish': {
    187                     if ( has_action( 'admin_notices' ) ) {
    188                         $passed[] = 'gate_publish';
    189                     } else {
    190                         $warnings[] = 'gate_publish — admin_notices hook not detected in current context.';
    191                     }
    192                     break;
     89                    case 'failed':
     90                        $failed[] = $line;
     91                        break;
     92                    default:
     93                        $warnings[] = $line;
     94                        break;
    19395                }
    19496            }
    19597        }
    19698
    197         $total = count( $passed ) + count( $failed );
    198 
    199         return self::result( $passed, $failed, $warnings, count( $passed ), $total );
     99        return self::result( $passed, $failed, $warnings, $unavailable );
    200100    }
    201101
    … …  
    206106        array $failed,
    207107        array $warnings,
    208         int   $pass_count,
    209         int   $total
     108        array $unavailable
    210109    ): array {
     110        $pass_count = count( $passed );
     111        $total      = $pass_count + count( $failed );
     112
    211113        if ( $total === 0 ) {
    212             $summary = 'No checkable structures found.';
     114            $summary = empty( $unavailable ) ? 'No checkable structures found.' : 'No checks could run.';
    213115        } elseif ( empty( $failed ) ) {
    214116            $summary = "All checks passed ({$pass_count}/{$total}).";
    … …  
    218120        }
    219121
     122        if ( ! empty( $unavailable ) ) {
     123            $summary .= ' ' . count( $unavailable ) . ' structure(s) have no verification declared.';
     124        }
     125
    220126        return [
    221             'passed'   => $passed,
    222             'failed'   => $failed,
    223             'warnings' => $warnings,
    224             'summary'  => $summary,
     127            'passed'      => $passed,
     128            'failed'      => $failed,
     129            'warnings'    => $warnings,
     130            'unavailable' => $unavailable,
     131            'summary'     => $summary,
    225132        ];
    226133    }
  • clautron/tags/1.5.0/includes/primitives/class-primitive-ai.php

    r3691441 r3784837  
    2727            'category'    => 'ai',
    2828            'phase'       => 'runtime',
     29            'effects'     => [ 'external_requests' ],
    2930            'label'       => 'AI Generate',
    3031            'description' => 'Generate text with the WordPress AI Client (site-configured provider) and store the result in a context variable. Use for excerpts, meta descriptions, alt text, translations, summaries. Prompt supports {{variable}} placeholders. Output is plain text only.',
  • clautron/tags/1.5.0/includes/primitives/class-primitive-comments.php

    r3691441 r3784837  
    2929            'category'    => 'comments',
    3030            'phase'       => 'runtime',
     31            'effects'     => [],
    3132            'label'       => 'Query Comments',
    3233            'description' => 'Query WordPress comments with filters. Returns array of comments (id, post_id, author, author_email, content, status, date, parent_id) stored in a context variable.',
    … …  
    8586            'category'    => 'comments',
    8687            'phase'       => 'runtime',
     88            // approve: core emails the post author (wp_set_comment_status → wp_new_comment_notify_postauthor).
     89            'effects'     => static fn( array $c ): array => [ 'trash' => [ 'writes_database', 'deletes_data' ], 'approve' => [ 'writes_database', 'external_requests' ] ][ $c['status'] ?? '' ] ?? [ 'writes_database' ],
     90            'effects_max' => [ 'writes_database', 'deletes_data', 'external_requests' ],
    8791            'label'       => 'Moderate Comment',
    8892            'description' => 'Change a comment\'s moderation status. Allowed statuses: approve, hold, spam, trash. Hard deletion is intentionally not supported.',
    … …  
    123127            'category'    => 'comments',
    124128            'phase'       => 'runtime',
     129            'effects'     => [ 'writes_database' ],
    125130            'label'       => 'Create Comment',
    126131            'description' => 'Insert a comment (or a threaded reply via parent_id) on a post. Content supports {{variable}} placeholders.',
  • clautron/tags/1.5.0/includes/primitives/class-primitive-cookie.php

    r3691441 r3784837  
    3737            'category'    => 'data',
    3838            'phase'       => 'runtime',
     39            'effects'     => [],
     40            'reversible'  => false,
    3941            'safe'        => false,
    4042            'label'       => 'Write Cookie',
    … …  
    8385            'category'    => 'data',
    8486            'phase'       => 'runtime',
     87            'effects'     => [],
    8588            'label'       => 'Read Cookie',
    8689            'description' => 'Read a clautron_-prefixed cookie into a workflow variable. Resolves to null if the cookie is not set.',
  • clautron/tags/1.5.0/includes/primitives/class-primitive-data.php

    r3582869 r3784837  
    3636            'category'    => 'data',
    3737            'phase'       => 'runtime',
     38            'effects'     => [],
    3839            'label'       => 'Read Post Field',
    3940            'description' => 'Read a field from the current post (ID, title, content, status, author, etc.) and store it in a variable.',
    … …  
    8586            'category'    => 'data',
    8687            'phase'       => 'runtime',
     88            'effects'     => [ 'writes_database' ],
    8789            'label'       => 'Write Meta',
    8890            'description' => 'Write a meta value to a post, user, or term.',
    … …  
    122124            'category'    => 'data',
    123125            'phase'       => 'runtime',
     126            'effects'     => [],
    124127            'label'       => 'Read Meta',
    125128            'description' => 'Read a meta value from a post, user, or term and store it in a variable.',
    … …  
    161164            'category'    => 'data',
    162165            'phase'       => 'runtime',
     166            'effects'     => [ 'writes_database' ],
    163167            'safe'        => false,
    164168            'label'       => 'Write Option',
    … …  
    187191            'category'    => 'data',
    188192            'phase'       => 'runtime',
     193            'effects'     => [],
    189194            'label'       => 'Read Option',
    190195            'description' => 'Read a WordPress option and store it in a variable.',
    … …  
    324329            'category'    => 'wordpress',
    325330            'phase'       => 'compile',
     331            'effects'     => [ 'writes_database' ],
    326332            'label'       => 'Gate Publish',
    327333            'description' => 'Block or warn when publishing a post that fails completeness checks.',
    … …  
    407413                    echo '</p></div>';
    408414                } );
     415            },
     416            'verify' => static function ( array $config ): array {
     417                if ( has_action( 'admin_notices' ) ) {
     418                    return [ [ 'status' => 'passed', 'label' => 'gate_publish' ] ];
     419                }
     420                return [ [ 'status' => 'warning', 'label' => 'gate_publish', 'reason' => 'admin_notices hook not detected in current context.' ] ];
    409421            },
    410422        ] );
    … …  
    478490            'category'    => 'data',
    479491            'phase'       => 'runtime',
     492            'effects'     => [ 'writes_database' ],
    480493            'label'       => 'Check Fields Completeness',
    481494            'description' => 'Evaluate a list of post fields and calculate a completeness score (0-100). Saves the score to a meta key and an output variable.',
    … …  
    563576            'category'    => 'data',
    564577            'phase'       => 'runtime',
     578            'effects'     => [],
    565579            'label'       => 'Run Report',
    566580            'description' => 'Execute a saved report blueprint and store its result rows in a context variable. Use with foreach_query_result to process each row.',
  • clautron/tags/1.5.0/includes/primitives/class-primitive-elementor.php

    r3592659 r3784837  
    3939            'category'    => 'elementor',
    4040            'phase'       => 'compile',
     41            'effects'     => [ 'executes_code' ],
    4142            'label'       => 'Elementor Widget',
    4243            'description' => 'Create a custom Elementor widget with content controls, style tab sections, and a server-rendered HTML template. Appears under the "Clautron" category in the Elementor panel.',
    … …  
    9798            'category'    => 'wordpress',
    9899            'phase'       => 'compile',
     100            'effects'     => [ 'executes_code' ],
    99101            'label'       => 'WordPress Widget',
    100102            'description' => 'Register a native WordPress sidebar widget (WP_Widget) usable in Appearance > Widgets and any theme sidebar. Supports text, textarea, number, url, color, checkbox, and select controls. Use {{key}} (escaped), {{{key}}} (HTML), {{key.url}} (URL), and {{#if key}}...{{/if}} in the template.',
  • clautron/tags/1.5.0/includes/primitives/class-primitive-email.php

    r3691441 r3784837  
    2020            'category'    => 'communication',
    2121            'phase'       => 'runtime',
     22            'effects'     => [ 'external_requests' ],
    2223            'label'       => 'Send Email',
    2324            'description' => 'Send an email via wp_mail(). All fields support {{variable}} template syntax.',
    … …  
    6970            'category'    => 'communication',
    7071            'phase'       => 'runtime',
     72            'effects'     => [ 'external_requests' ],
    7173            'label'       => 'Notify User',
    7274            'description' => 'Send a notification email to a WordPress user by their user ID. Subject and body support {{variable}} template syntax.',
    … …  
    129131            'category'    => 'communication',
    130132            'phase'       => 'compile',
     133            'effects'     => [ 'external_requests' ],
    131134            'safe'        => false,
    132135            'label'       => 'Configure SMTP',
  • clautron/tags/1.5.0/includes/primitives/class-primitive-events.php

    r3691441 r3784837  
    3232            'category'    => 'events',
    3333            'phase'       => 'runtime',
     34            'effects'     => [ 'writes_database' ],
    3435            'label'       => 'Track Event',
    3536            'description' => 'Write one event to the clautron_events time-series store. Use this inside any blueprint trigger to record that something happened (a pageview, a login failure, an order, a cron run, etc.).',
    … …  
    7879            'category'    => 'events',
    7980            'phase'       => 'runtime',
     81            'effects'     => [],
    8082            'label'       => 'Query Events',
    8183            'description' => 'Read raw event rows from the clautron_events store. Results are stored in output_variable for use in subsequent primitives (foreach_query_result, send_email, etc.).',
    … …  
    120122            'category'    => 'events',
    121123            'phase'       => 'runtime',
     124            'effects'     => [],
    122125            'label'       => 'Aggregate Events',
    123126            'description' => 'Group and aggregate event data: total counts, sums, averages, grouped by time period or object. Returns an array of {period, value} rows in output_variable.',
    … …  
    160163            'category'    => 'events',
    161164            'phase'       => 'runtime',
     165            'effects'     => [ 'writes_database', 'deletes_data' ],
    162166            'label'       => 'Purge Events',
    163167            'description' => 'Delete old events for retention management. Use inside a recurring job (e.g. weekly). Returns the count of deleted rows in output_variable.',
    … …  
    201205            'category'    => 'events',
    202206            'phase'       => 'compile',
     207            'effects'     => [ 'writes_database' ],
    203208            'safe'        => false,
    204209            'label'       => 'Log All Outgoing Email',
    … …  
    256261            'category'    => 'events',
    257262            'phase'       => 'compile',
     263            'effects'     => [ 'writes_database' ],
    258264            'safe'        => false,
    259265            'label'       => 'Log Site Activity',
  • clautron/tags/1.5.0/includes/primitives/class-primitive-filter.php

    r3691441 r3784837  
    3333            'category'    => 'frontend',
    3434            'phase'       => 'compile',
     35            'effects'     => [],
    3536            'label'       => 'Filter Query by Request Parameters',
    3637            'description' => 'Restrict frontend main queries (archives, search, home) using one or more filter rules read from GET parameters: taxonomy facets, exact meta match, or numeric meta range. Built for shop/archive filter sidebars (category, in-stock, price range). Never touches wp-admin or REST requests.',
  • clautron/tags/1.5.0/includes/primitives/class-primitive-frontend.php

    r3691441 r3784837  
    2626            'category'    => 'frontend',
    2727            'phase'       => 'compile',
     28            'effects'     => [ 'executes_code' ],
    2829            'label'       => 'Inject into <head>',
    2930            'description' => 'Output HTML into wp_head on singular post/page views. Supports {{post.field}}, {{site.field}}, and {{_meta_key}} placeholders. Optionally outputs a Schema.org JSON-LD block via json_ld config.',
    … …  
    118119                }, $priority );
    119120            },
     121            'verify' => static function ( array $config ): array {
     122                $priority = (int) ( $config['priority'] ?? 5 );
     123                if ( has_action( 'wp_head' ) ) {
     124                    return [ [ 'status' => 'passed', 'label' => "inject_wp_head (priority:{$priority})" ] ];
     125                }
     126                return [ [ 'status' => 'failed', 'label' => 'inject_wp_head', 'reason' => 'no actions registered on wp_head.' ] ];
     127            },
    120128        ] );
    121129    }
    … …  
    127135            'category'    => 'frontend',
    128136            'phase'       => 'compile',
     137            'effects'     => [ 'executes_code' ],
    129138            'label'       => 'Inject before </body>',
    130139            'description' => 'Output HTML into wp_footer. Same {{variable}} resolution as inject_wp_head (post.*, site.*, meta keys). Leave post_types empty to render on every frontend page (banners, popups, share widgets) — set it to restrict to singular views of those post types, where {{post.*}}/meta-key tokens resolve against the viewed post. Set suppress_if_cookie to hide the whole block once a clautron_-prefixed cookie (e.g. one set by write_cookie) has any non-empty value — the standard pattern for "don\'t show this again after accept/dismiss".',
    … …  
    180189                }, $priority );
    181190            },
     191            'verify' => static function ( array $config ): array {
     192                if ( has_action( 'wp_footer' ) ) {
     193                    return [ [ 'status' => 'passed', 'label' => 'inject_wp_footer' ] ];
     194                }
     195                return [ [ 'status' => 'failed', 'label' => 'inject_wp_footer', 'reason' => 'no actions registered on wp_footer.' ] ];
     196            },
    182197        ] );
    183198    }
    … …  
    189204            'category'    => 'frontend',
    190205            'phase'       => 'compile',
     206            'effects'     => [ 'executes_code' ],
    191207            'label'       => 'Register Shortcode',
    192208            'description' => 'Register a WordPress [shortcode] rendered from an HTML template. Attributes become template tokens: {{attr}} escaped text, {{{attr}}} sanitized HTML, {{attr.url}} URL, {{#if attr}}...{{/if}} conditionals. Enclosed content is available as {{{content}}}.',
    … …  
    272288            'category'    => 'frontend',
    273289            'phase'       => 'compile',
     290            'effects'     => [],
    274291            'label'       => 'Enqueue Registered Asset',
    275292            'description' => 'Load CSS/JS assets that are ALREADY registered on the site (by core, the theme, or plugins) onto frontend pages, by handle. Never loads external URLs or new code — only activates assets the site already ships (e.g. dashicons, wp-block-library, swiper, theme handles). Use widget/shortcode templates with the classes those assets provide.',
  • clautron/tags/1.5.0/includes/primitives/class-primitive-http.php

    r3582869 r3784837  
    2424            'category'    => 'http',
    2525            'phase'       => 'runtime',
     26            'effects'     => [ 'external_requests' ],
     27            'safe'        => false,
    2628            'label'       => 'HTTP Request',
    2729            'description' => 'Send an HTTP request (GET, POST, PUT, DELETE, PATCH) and store the response body and status code in context variables.',
    … …  
    112114            'category'    => 'http',
    113115            'phase'       => 'runtime',
     116            'effects'     => [ 'external_requests', 'writes_database' ], // SimplePie caches the feed in transients
     117            'safe'        => false,
    114118            'label'       => 'Fetch RSS/Atom Feed',
    115119            'description' => 'Fetch an RSS or Atom feed and store items as an array of {title, link, description, pubdate} objects.',
    116             'safe'        => true,
    117120            'schema'      => [
    118121                'url'             => [ 'type' => 'string',  'required' => true, 'description' => 'Feed URL. Supports {{variable}}.' ],
    … …  
    170173            'category'    => 'http',
    171174            'phase'       => 'runtime',
     175            'effects'     => [ 'external_requests' ],
    172176            'label'       => 'Send Webhook',
    173177            'description' => 'POST a JSON payload to a webhook URL. Supports {{variable}} in URL and payload values.',
  • clautron/tags/1.5.0/includes/primitives/class-primitive-language.php

    r3691441 r3784837  
    2929            'category'    => 'frontend',
    3030            'phase'       => 'compile',
     31            'effects'     => [],
    3132            'label'       => 'Filter Query by Language',
    3233            'description' => 'Restrict frontend main queries (archives, search, home, singular) to posts tagged with one term of a language taxonomy. Language is resolved from a URL query var, falling back to a cookie, falling back to a configured default. Never touches wp-admin or REST requests.',
  • clautron/tags/1.5.0/includes/primitives/class-primitive-logic.php

    r3582869 r3784837  
    3030            'category'    => 'logic',
    3131            'phase'       => 'runtime',
     32            'effects'     => [],
    3233            'label'       => 'If / Else',
    3334            'description' => 'Evaluate a condition. Execute pass_branch nodes if true, fail_branch nodes if false.',
    … …  
    7475            'category'    => 'logic',
    7576            'phase'       => 'runtime',
     77            'effects'     => [],
    7678            'label'       => 'Stop Execution',
    7779            'description' => 'Halt workflow execution immediately. No further nodes will run.',
    … …  
    8991            'category'    => 'logic',
    9092            'phase'       => 'runtime',
     93            'effects'     => [],
    9194            'label'       => 'Set Variable',
    9295            'description' => 'Assign a literal or resolved value to a context variable for use in later nodes.',
    … …  
    111114            'category'    => 'logic',
    112115            'phase'       => 'runtime',
     116            'effects'     => [],
    113117            'label'       => 'For Each Row',
    114118            'description' => 'Iterate over rows from a query result stored in a context variable. Each row\'s columns become available as {{row.column_name}} for the body nodes.',
    … …  
    181185            'category'    => 'logic',
    182186            'phase'       => 'runtime',
     187            'effects'     => [],
    183188            'label'       => 'Switch / Case',
    184189            'description' => 'Evaluate a value against multiple cases and execute the matching branch. Falls through to default_branch if no case matches.',
  • clautron/tags/1.5.0/includes/primitives/class-primitive-media.php

    r3691441 r3784837  
    2727            'category'    => 'media',
    2828            'phase'       => 'runtime',
     29            'effects'     => [ 'writes_database', 'writes_files', 'external_requests' ],
    2930            'label'       => 'Create Media from URL',
    3031            'description' => 'Download an image from a URL and add it to the WordPress media library. Returns the new attachment ID.',
    … …  
    8182            'category'    => 'media',
    8283            'phase'       => 'runtime',
     84            'effects'     => [ 'writes_database' ],
    8385            'label'       => 'Set Featured Image',
    8486            'description' => 'Set the featured image (thumbnail) of a post to a given attachment ID.',
    … …  
    120122            'category'    => 'media',
    121123            'phase'       => 'runtime',
     124            'effects'     => [],
    122125            'label'       => 'Get Image URL',
    123126            'description' => 'Get the URL of an attachment image at a given size.',
    … …  
    150153            'category'    => 'media',
    151154            'phase'       => 'runtime',
     155            'effects'     => [ 'writes_database', 'writes_files' ],
    152156            'safe'        => false,
    153157            'label'       => 'Handle File Upload',
    … …  
    162166            'handler' => static function ( array $config, CLAUTRON_Workflow_Context $ctx ): int|false {
    163167                $out_var = $config['output_variable'] ?? 'uploaded_attachment_id';
    164                 $fail    = static function () use ( $ctx, $out_var ): false {
     168                $fail    = static function () use ( $ctx, $out_var ): bool {
    165169                    if ( $out_var ) {
    166170                        $ctx->write_variable( $out_var, false );
  • clautron/tags/1.5.0/includes/primitives/class-primitive-query.php

    r3691441 r3784837  
    3232            'category'    => 'query',
    3333            'phase'       => 'runtime',
     34            'effects'     => [],
    3435            'label'       => 'Query Posts',
    3536            'description' => 'Query WordPress posts with flexible filters. Returns array of post objects (id, title, content, excerpt, status, type, author, date, permalink) stored in a context variable.',
    … …  
    9798            'category'    => 'query',
    9899            'phase'       => 'runtime',
     100            'effects'     => [],
    99101            'label'       => 'Get Post',
    100102            'description' => 'Fetch a single post by ID and store its fields in a context variable.',
    … …  
    133135            'category'    => 'query',
    134136            'phase'       => 'runtime',
     137            'effects'     => [],
    135138            'label'       => 'Count Posts',
    136139            'description' => 'Count posts matching given criteria. Writes integer to output_variable.',
    … …  
    191194            'category'    => 'query',
    192195            'phase'       => 'runtime',
     196            'effects'     => [],
    193197            'label'       => 'Query Users',
    194198            'description' => 'Query WordPress users with flexible filters. Returns array of user objects.',
    … …  
    246250            'category'    => 'query',
    247251            'phase'       => 'runtime',
     252            'effects'     => [],
    248253            'label'       => 'Get User',
    249254            'description' => 'Fetch a single user by ID and store their data in a context variable.',
    … …  
    282287            'category'    => 'query',
    283288            'phase'       => 'runtime',
     289            'effects'     => [],
    284290            'label'       => 'Query Terms',
    285291            'description' => 'Query taxonomy terms. Returns array of term objects.',
  • clautron/tags/1.5.0/includes/primitives/class-primitive-redirect.php

    r3691441 r3784837  
    2020            'category'    => 'frontend',
    2121            'phase'       => 'compile',
     22            'effects'     => [],
     23            // Browsers cache permanent redirects indefinitely; removing the rule doesn't reach them.
     24            'reversible'  => static fn( array $c ): bool => ! in_array( (int) ( $c['status'] ?? 301 ), [ 301, 308 ], true ),
    2225            'label'       => 'Redirect URL',
    2326            'description' => 'Redirect requests from one path to another. Refuses redirects that would break wp-admin/login/REST access or create a loop.',
  • clautron/tags/1.5.0/includes/primitives/class-primitive-request.php

    r3691441 r3784837  
    2525            'category'    => 'data',
    2626            'phase'       => 'runtime',
     27            'effects'     => [],
    2728            'safe'        => false,
    2829            'label'       => 'Read Request Parameter',
  • clautron/tags/1.5.0/includes/primitives/class-primitive-security.php

    r3691441 r3784837  
    33
    44/**
    5  * Security Primitives — abuse throttling.
     5 * Security Primitives — abuse throttling and information-disclosure hardening.
    66 *
    77 * Registered primitives:
    8  *   - rate_limit_check   Fixed-window counter (transient-backed) keyed by an
    9  *                         identifier (defaults to client IP). Use as a
    10  *                         condition to gate login/comment/form actions.
     8 *   - rate_limit_check          Fixed-window counter (transient-backed) keyed by an
     9 *                                identifier (defaults to client IP). Use as a
     10 *                                condition to gate login/comment/form actions.
     11 *   - harden_user_enumeration   Stop anonymous visitors from discovering usernames
     12 *                                (REST users endpoint, author archives, oEmbed
     13 *                                author, login error messages).
     14 *   - security_headers          Send Referrer-Policy, X-Content-Type-Options,
     15 *                                X-Frame-Options and Permissions-Policy on
     16 *                                frontend responses, and HSTS on HTTPS (opt-in).
     17 *   - hide_wp_version           Remove generator tags, mask the WordPress
     18 *                                version in core asset URLs and, opt-in,
     19 *                                delete readme.html.
     20 *
     21 * The hardening primitives hook named static methods rather than closures
     22 * wherever the callback needs no config: add_filter() with the same callable
     23 * and priority is idempotent, so two blueprints declaring the same primitive
     24 * do not stack duplicate callbacks, and verify can check the exact callable
     25 * with has_filter() instead of "something is hooked here".
    1126 */
    1227class CLAUTRON_Primitive_Security extends CLAUTRON_Abstract_Primitive {
    1328
     29    /** Login error codes that reveal whether a username/email exists. */
     30    private const LOGIN_ENUMERATION_CODES = [ 'invalid_username', 'invalid_email', 'incorrect_password' ];
     31
     32    private const REFERRER_POLICIES = [
     33        'no-referrer', 'no-referrer-when-downgrade', 'origin', 'origin-when-cross-origin',
     34        'same-origin', 'strict-origin', 'strict-origin-when-cross-origin', 'unsafe-url',
     35    ];
     36
     37    private const FRAME_OPTIONS = [ 'SAMEORIGIN', 'DENY' ];
     38
     39    private const PERMISSIONS_POLICY = 'camera=(), microphone=()';
     40
     41    /** WordPress version readme.html was last removed for (see remove_core_readme()). */
     42    private const README_OPTION = 'clautron_readme_removed_for';
     43
     44    /** Headers merged from every active security_headers structure; the first one to set a header wins. */
     45    private static array $security_headers = [];
     46
    1447    public static function register_all(): void {
    1548        self::register_rate_limit_check();
     49        self::register_harden_user_enumeration();
     50        self::register_security_headers();
     51        self::register_hide_wp_version();
    1652    }
    1753
    … …  
    2258            'category'    => 'security',
    2359            'phase'       => 'runtime',
     60            'effects'     => [ 'writes_database' ],
    2461            'safe'        => false,
    2562            'label'       => 'Rate Limit Check',
    … …  
    86123        return false;
    87124    }
     125
     126    // ── harden_user_enumeration ──────────────────────────────────────────────
     127
     128    private static function register_harden_user_enumeration(): void {
     129        CLAUTRON_Primitive_Registry::register( 'harden_user_enumeration', [
     130            'category'    => 'security',
     131            'phase'       => 'compile',
     132            'effects'     => [],
     133            'safe'        => false,
     134            'label'       => 'Harden User Enumeration',
     135            'description' => 'Stop anonymous visitors from discovering usernames. Blocks the /wp/v2/users REST endpoints for logged-out requests (logged-in users, including the block editor author picker, keep working); returns 404 for every author archive (?author=N, ?author_name=, /author/slug/ and their feeds), drops the users sitemap and points author links at the homepage; replaces the post author in oEmbed responses with the site name; and gives the same login error for an unknown username and a wrong password. Each part can be switched off individually.',
     136            'schema'      => [
     137                'block_rest_users'        => [ 'type' => 'boolean', 'default' => true, 'description' => '401 on every /wp/v2/users route for logged-out requests.' ],
     138                'disable_author_archives' => [ 'type' => 'boolean', 'default' => true, 'description' => '404 on all author archives for everyone, remove the users sitemap, point author links to the homepage.' ],
     139                'strip_oembed_author'     => [ 'type' => 'boolean', 'default' => true, 'description' => 'oEmbed responses show the site name and homepage instead of the post author.' ],
     140                'generic_login_errors'    => [ 'type' => 'boolean', 'default' => true, 'description' => 'Same login error whether the username/email is unknown or the password is wrong.' ],
     141            ],
     142            'examples'    => [
     143                [],
     144                [ 'disable_author_archives' => false ],
     145            ],
     146            'handler' => static function ( array $config ): void {
     147                if ( $config['block_rest_users'] ?? true ) {
     148                    add_filter( 'rest_request_before_callbacks', [ self::class, 'gate_rest_users' ], 10, 3 );
     149                }
     150                if ( $config['disable_author_archives'] ?? true ) {
     151                    add_filter( 'request', [ self::class, 'block_author_request' ] );
     152                    add_filter( 'wp_sitemaps_add_provider', [ self::class, 'drop_users_sitemap' ], 10, 2 );
     153                    add_filter( 'author_link', [ self::class, 'neutral_author_link' ] );
     154                }
     155                if ( $config['strip_oembed_author'] ?? true ) {
     156                    add_filter( 'oembed_response_data', [ self::class, 'neutral_oembed_author' ] );
     157                }
     158                if ( $config['generic_login_errors'] ?? true ) {
     159                    // After every core authenticator (20) and the spam check (99).
     160                    add_filter( 'authenticate', [ self::class, 'generic_login_error' ], 100 );
     161                }
     162            },
     163            'verify' => static function ( array $config ): array {
     164                $items  = [];
     165                $checks = [
     166                    'block_rest_users'        => [ 'rest_request_before_callbacks', 'gate_rest_users' ],
     167                    'disable_author_archives' => [ 'request', 'block_author_request' ],
     168                    'strip_oembed_author'     => [ 'oembed_response_data', 'neutral_oembed_author' ],
     169                    'generic_login_errors'    => [ 'authenticate', 'generic_login_error' ],
     170                ];
     171                foreach ( $checks as $option => [ $hook, $method ] ) {
     172                    if ( ! ( $config[ $option ] ?? true ) ) {
     173                        continue;
     174                    }
     175                    $items[] = false !== has_filter( $hook, [ self::class, $method ] )
     176                        ? [ 'status' => 'passed', 'label' => "harden_user_enumeration:{$option}" ]
     177                        : [ 'status' => 'failed', 'label' => "harden_user_enumeration:{$option}", 'reason' => "not hooked on {$hook}." ];
     178                }
     179
     180                if ( $config['block_rest_users'] ?? true ) {
     181                    $status  = self::anonymous_rest_status( '/wp/v2/users' );
     182                    $items[] = in_array( $status, [ 401, 403 ], true )
     183                        ? [ 'status' => 'passed', 'label' => "anonymous GET /wp/v2/users → {$status}" ]
     184                        : [ 'status' => 'failed', 'label' => 'anonymous GET /wp/v2/users', 'reason' => "expected 401, got {$status}." ];
     185                }
     186
     187                if ( $config['disable_author_archives'] ?? true ) {
     188                    $vars    = self::author_query_vars( [ 'author' => '1' ], false );
     189                    $items[] = ( $vars['error'] ?? '' ) === '404'
     190                        ? [ 'status' => 'passed', 'label' => '?author=1 resolves to 404' ]
     191                        : [ 'status' => 'failed', 'label' => '?author=1', 'reason' => 'author query was not turned into a 404.' ];
     192                }
     193
     194                return $items;
     195            },
     196        ] );
     197    }
     198
     199    /**
     200     * rest_request_before_callbacks: refuse every route served by the core
     201     * users controller to logged-out requests.
     202     *
     203     * Keyed on the controller that matched, not on the URL: WordPress matches
     204     * routes case-insensitively (/wp/v2/USERS works) and also accepts
     205     * ?rest_route=, so a path check would have to replicate the router to be
     206     * airtight. Whatever spelling reached the users controller is caught here.
     207     */
     208    public static function gate_rest_users( mixed $response, array $handler, WP_REST_Request $request ): mixed {
     209        $callback = $handler['callback'] ?? null;
     210        if ( ! is_array( $callback ) || ! ( $callback[0] ?? null ) instanceof WP_REST_Users_Controller ) {
     211            return $response;
     212        }
     213        if ( is_user_logged_in() ) {
     214            return $response;
     215        }
     216        return new WP_Error(
     217            'rest_forbidden',
     218            __( 'Sorry, you are not allowed to do that.', 'clautron' ),
     219            [ 'status' => rest_authorization_required_code() ]
     220        );
     221    }
     222
     223    /** request filter: wiring for author_query_vars(). */
     224    public static function block_author_request( array $query_vars ): array {
     225        return self::author_query_vars( $query_vars, is_admin() );
     226    }
     227
     228    /**
     229     * Replace any author-archive query with a plain 404. Public for unit
     230     * testing (see CLAUTRON_Primitive_WordPress::build_cpt_args()).
     231     *
     232     * Runs on `request`, before WP_Query exists, so every way of asking for an
     233     * author archive is covered by one check: ?author=N, ?author_name=slug,
     234     * /author/slug/ (rewrite sets author_name) and their /feed/ variants.
     235     * Blocking only the canonical redirect is not enough: an author without
     236     * published posts gets no redirect, but the archive still prints the slug
     237     * in <title> and body_class.
     238     *
     239     * Left alone:
     240     *   - wp-admin, because edit.php?author=N calls wp() and goes through this
     241     *     same filter to filter the posts list;
     242     *   - REST requests, because /wp-json/wp/v2/posts?author=N is a legitimate
     243     *     filter and `author` is a public query var.
     244     */
     245    public static function author_query_vars( array $query_vars, bool $in_admin ): array {
     246        if ( $in_admin || isset( $query_vars['rest_route'] ) ) {
     247            return $query_vars;
     248        }
     249        if ( empty( $query_vars['author'] ) && empty( $query_vars['author_name'] ) ) {
     250            return $query_vars;
     251        }
     252        // Same shape WP::parse_request() produces for an unmatched URL, so
     253        // WP::send_headers() sends the 404 status and nocache headers itself.
     254        return [ 'error' => '404' ];
     255    }
     256
     257    /** wp_sitemaps_add_provider: the users sitemap only lists author archive URLs. */
     258    public static function drop_users_sitemap( mixed $provider, string $name ): mixed {
     259        return 'users' === $name ? false : $provider;
     260    }
     261
     262    /** author_link: author archives are gone, and the link itself carries the slug. */
     263    public static function neutral_author_link(): string {
     264        return home_url( '/' );
     265    }
     266
     267    /**
     268     * oembed_response_data: use the values core itself falls back to when a
     269     * post has no author, instead of display_name + author archive URL.
     270     */
     271    public static function neutral_oembed_author( array $data ): array {
     272        if ( isset( $data['author_name'] ) || isset( $data['author_url'] ) ) {
     273            $data['author_name'] = get_bloginfo( 'name' );
     274            $data['author_url']  = get_home_url();
     275        }
     276        return $data;
     277    }
     278
     279    /**
     280     * authenticate: collapse "unknown username", "unknown email" and "wrong
     281     * password" into one error.
     282     *
     283     * Reuses core's own 'invalidcombo' code: it is already in wp-login.php's
     284     * shake list, and it keeps wp-login.php from pre-filling the username
     285     * field only on a wrong password, which would otherwise leak the same
     286     * information through the form instead of the message.
     287     */
     288    public static function generic_login_error( mixed $user ): mixed {
     289        if ( ! is_wp_error( $user ) || ! array_intersect( $user->get_error_codes(), self::LOGIN_ENUMERATION_CODES ) ) {
     290            return $user;
     291        }
     292        return new WP_Error(
     293            'invalidcombo',
     294            __( '<strong>Error:</strong> The username, email address or password is incorrect.', 'clautron' )
     295        );
     296    }
     297
     298    /** Dispatch a GET as a logged-out visitor and return the HTTP status. */
     299    private static function anonymous_rest_status( string $route ): int {
     300        $previous_user = get_current_user_id();
     301        wp_set_current_user( 0 );
     302        try {
     303            return rest_do_request( new WP_REST_Request( 'GET', $route ) )->get_status();
     304        } finally {
     305            wp_set_current_user( $previous_user );
     306        }
     307    }
     308
     309    // ── security_headers ─────────────────────────────────────────────────────
     310
     311    private static function register_security_headers(): void {
     312        CLAUTRON_Primitive_Registry::register( 'security_headers', [
     313            'category'    => 'security',
     314            'phase'       => 'compile',
     315            'effects'     => [],
     316            // Browsers keep honouring HSTS for max-age after the header stops.
     317            'reversible'  => static fn( array $c ): bool => (int) ( $c['hsts_max_age'] ?? 0 ) <= 0,
     318            'safe'        => false,
     319            'label'       => 'Security Headers',
     320            'description' => 'Send Referrer-Policy, X-Content-Type-Options, X-Frame-Options and Permissions-Policy on frontend responses, plus Strict-Transport-Security (HSTS) on HTTPS when opted in. A header already set by another plugin through wp_headers is left as is. Headers are sent from PHP, so pages served straight from a page cache or CDN without running WordPress will not carry them; verify loads the homepage to detect that.',
     321            'schema'      => [
     322                'referrer_policy'         => [ 'type' => 'string',  'default' => 'strict-origin-when-cross-origin', 'description' => 'Any standard Referrer-Policy value. Empty = do not send. strict-origin-when-cross-origin matches what WordPress core sends in wp-admin; no-referrer breaks YouTube embeds and referral analytics.' ],
     323                'content_type_options'    => [ 'type' => 'boolean', 'default' => true, 'description' => 'Send X-Content-Type-Options: nosniff.' ],
     324                'frame_options'           => [ 'type' => 'string',  'default' => 'SAMEORIGIN', 'description' => 'SAMEORIGIN, DENY, or empty to not send. SAMEORIGIN keeps the Customizer and page-builder previews working.' ],
     325                'permissions_policy'      => [ 'type' => 'string',  'default' => self::PERMISSIONS_POLICY, 'description' => 'Permissions-Policy value, e.g. "camera=(), microphone=(), geolocation=(self)". Empty = do not send; an invalid value is not sent. The default turns off camera and microphone only: blocking geolocation would break store/branch locators, and payment would break Apple Pay / Google Pay buttons.' ],
     326                'hsts_max_age'            => [ 'type' => 'integer', 'default' => 0, 'description' => 'Opt-in Strict-Transport-Security max-age in seconds (31536000 = one year). 0 = do not send. Only sent over HTTPS. Browsers then refuse plain HTTP for that long and deactivating does not undo it, so turn it on only when the whole site works over HTTPS.' ],
     327                'hsts_include_subdomains' => [ 'type' => 'boolean', 'default' => false, 'description' => 'Add includeSubDomains to HSTS. Only when every subdomain serves HTTPS.' ],
     328            ],
     329            'examples'    => [
     330                [],
     331                [ 'permissions_policy' => 'camera=(), microphone=(), geolocation=(self)', 'hsts_max_age' => 31536000 ],
     332            ],
     333            'handler' => static function ( array $config ): void {
     334                $headers = self::build_security_headers( $config );
     335                if ( ! $headers ) {
     336                    return;
     337                }
     338                self::$security_headers = self::merge_headers( self::$security_headers, $headers );
     339                add_filter( 'wp_headers', [ self::class, 'send_security_headers' ] );
     340            },
     341            'verify' => static function ( array $config ): array {
     342                $expected = self::build_security_headers( $config );
     343                if ( ! $expected ) {
     344                    return [];
     345                }
     346                if ( false === has_filter( 'wp_headers', [ self::class, 'send_security_headers' ] ) ) {
     347                    return [ [ 'status' => 'failed', 'label' => 'security_headers', 'reason' => 'not hooked on wp_headers.' ] ];
     348                }
     349
     350                $items = [];
     351                foreach ( $expected as $name => $value ) {
     352                    if ( ( self::$security_headers[ $name ] ?? null ) !== $value ) {
     353                        $items[] = [ 'status' => 'warning', 'label' => "security_headers:{$name}", 'reason' => 'another security_headers capability already sets it to "' . ( self::$security_headers[ $name ] ?? '' ) . '"; the first one wins.' ];
     354                    }
     355                }
     356
     357                $https = 'https' === wp_parse_url( home_url(), PHP_URL_SCHEME );
     358                if ( isset( $expected['Strict-Transport-Security'] ) && ! $https ) {
     359                    $items[] = [ 'status' => 'warning', 'label' => 'security_headers:Strict-Transport-Security', 'reason' => 'HSTS is only sent over HTTPS and this site\'s address is http://.' ];
     360                    unset( $expected['Strict-Transport-Security'] );
     361                }
     362
     363                $response = wp_remote_get( home_url( '/' ), [ 'timeout' => 5, 'redirection' => 3 ] );
     364                if ( is_wp_error( $response ) ) {
     365                    $items[] = [ 'status' => 'warning', 'label' => 'security_headers:live', 'reason' => 'could not load the homepage to confirm the headers reach visitors: ' . $response->get_error_message() ];
     366                    return $items;
     367                }
     368                foreach ( $expected as $name => $value ) {
     369                    $items[] = '' !== wp_remote_retrieve_header( $response, $name )
     370                        ? [ 'status' => 'passed', 'label' => "{$name} on homepage" ]
     371                        : [ 'status' => 'warning', 'label' => "{$name} on homepage", 'reason' => 'set by WordPress but missing from the live response; a page cache, CDN or proxy is serving the page without PHP or stripping the header. Set it at that layer too.' ];
     372                }
     373                return $items;
     374            },
     375        ] );
     376    }
     377
     378    /**
     379     * wp_headers: everything active security_headers structures asked for.
     380     * One named callback (not a closure per structure) so verify can check
     381     * that this primitive, not just any plugin, is hooked.
     382     */
     383    public static function send_security_headers( array $headers ): array {
     384        $ours = self::$security_headers;
     385        if ( ! is_ssl() ) {
     386            unset( $ours['Strict-Transport-Security'] );
     387        }
     388        return self::merge_headers( $headers, $ours );
     389    }
     390
     391    /**
     392     * Header name → value from a security_headers config. Invalid values are
     393     * dropped rather than sent, since a malformed Referrer-Policy or
     394     * X-Frame-Options is ignored by browsers anyway. Public for unit testing.
     395     */
     396    public static function build_security_headers( array $config ): array {
     397        $headers = [];
     398
     399        $referrer = strtolower( trim( (string) ( $config['referrer_policy'] ?? 'strict-origin-when-cross-origin' ) ) );
     400        if ( in_array( $referrer, self::REFERRER_POLICIES, true ) ) {
     401            $headers['Referrer-Policy'] = $referrer;
     402        }
     403
     404        if ( $config['content_type_options'] ?? true ) {
     405            $headers['X-Content-Type-Options'] = 'nosniff';
     406        }
     407
     408        $frame = strtoupper( trim( (string) ( $config['frame_options'] ?? 'SAMEORIGIN' ) ) );
     409        if ( in_array( $frame, self::FRAME_OPTIONS, true ) ) {
     410            $headers['X-Frame-Options'] = $frame;
     411        }
     412
     413        $permissions = trim( (string) ( $config['permissions_policy'] ?? self::PERMISSIONS_POLICY ) );
     414        $directive   = '[a-z0-9-]+=(?:\*|\((?:[ \t]*(?:self|src|\*|"https?:\/\/[^"\s]+"))*[ \t]*\))';
     415        if ( preg_match( '/^' . $directive . '(?:[ \t]*,[ \t]*' . $directive . ')*$/D', $permissions ) ) {
     416            $headers['Permissions-Policy'] = $permissions;
     417        }
     418
     419        $max_age = min( 63072000, (int) ( $config['hsts_max_age'] ?? 0 ) );
     420        if ( $max_age > 0 ) {
     421            $headers['Strict-Transport-Security'] = "max-age={$max_age}" . ( empty( $config['hsts_include_subdomains'] ) ? '' : '; includeSubDomains' );
     422        }
     423
     424        return $headers;
     425    }
     426
     427    /**
     428     * Add $ours to $existing without overriding a header someone else already
     429     * set (case-insensitive, like HTTP header names). Overriding could weaken
     430     * a stricter choice, e.g. a security plugin's X-Frame-Options: DENY.
     431     */
     432    public static function merge_headers( array $existing, array $ours ): array {
     433        $taken = array_change_key_case( $existing, CASE_LOWER );
     434        foreach ( $ours as $name => $value ) {
     435            if ( ! isset( $taken[ strtolower( $name ) ] ) ) {
     436                $existing[ $name ] = $value;
     437            }
     438        }
     439        return $existing;
     440    }
     441
     442    // ── hide_wp_version ──────────────────────────────────────────────────────
     443
     444    private static function register_hide_wp_version(): void {
     445        CLAUTRON_Primitive_Registry::register( 'hide_wp_version', [
     446            'category'    => 'security',
     447            'phase'       => 'compile',
     448            'effects'     => static fn( array $c ): array => empty( $c['remove_readme'] ) ? [] : [ 'writes_files', 'writes_database' ],
     449            'effects_max' => [ 'writes_files', 'writes_database' ],
     450            'safe'        => false,
     451            'label'       => 'Hide WordPress Version',
     452            'description' => 'Stop advertising the WordPress version. Removes the generator meta tag and the feed <generator> element (which also drops WooCommerce\'s generator line), turns off Elementor\'s generator tag without changing its saved setting, and replaces ?ver=<WordPress version> on asset URLs with a site-specific hash that still changes on every update, so browser caches are still refreshed. readme.html in the site root also states the version, but the web server serves it without WordPress, so nothing above hides it: set remove_readme to delete it (opt-in, a file deletion that deactivating does not undo).',
     453            'schema'      => [
     454                'remove_generator'    => [ 'type' => 'boolean', 'default' => true, 'description' => 'Remove generator tags (WordPress, WooCommerce, Elementor) from pages and feeds.' ],
     455                'mask_asset_versions' => [ 'type' => 'boolean', 'default' => true, 'description' => 'Replace ?ver=<WordPress version> on script/style URLs with a hash of it.' ],
     456                'remove_readme'       => [ 'type' => 'boolean', 'default' => false, 'description' => 'Delete readme.html from the site root, and again after every WordPress update (which restores it). Works on any web server. Deactivating does not bring the file back; the next WordPress update does.' ],
     457            ],
     458            'examples'    => [
     459                [],
     460                [ 'remove_readme' => true ],
     461            ],
     462            'handler' => static function ( array $config ): void {
     463                if ( ! empty( $config['remove_readme'] ) ) {
     464                    add_action( 'init', [ self::class, 'maybe_remove_core_readme' ] );
     465                }
     466                if ( $config['remove_generator'] ?? true ) {
     467                    add_filter( 'the_generator', '__return_empty_string' );
     468                    // Elementor prints its own tag on wp_head unless this option is '1'.
     469                    // pre_option_ overrides it for as long as the feature is active and
     470                    // leaves the stored value untouched when it is deactivated.
     471                    add_filter( 'pre_option_elementor_meta_generator_tag', [ self::class, 'elementor_generator_off' ] );
     472                }
     473                if ( $config['mask_asset_versions'] ?? true ) {
     474                    add_filter( 'script_loader_src', [ self::class, 'mask_core_version' ] );
     475                    add_filter( 'style_loader_src', [ self::class, 'mask_core_version' ] );
     476                }
     477            },
     478            'verify' => static function ( array $config ): array {
     479                $items = [];
     480                if ( $config['remove_generator'] ?? true ) {
     481                    $items[] = false !== has_filter( 'the_generator', '__return_empty_string' )
     482                        ? [ 'status' => 'passed', 'label' => 'hide_wp_version:remove_generator' ]
     483                        : [ 'status' => 'failed', 'label' => 'hide_wp_version:remove_generator', 'reason' => 'not hooked on the_generator.' ];
     484                }
     485                if ( $config['mask_asset_versions'] ?? true ) {
     486                    $version = get_bloginfo( 'version' );
     487                    $masked  = self::mask_core_version( includes_url( 'js/jquery/jquery.min.js?ver=' . $version ) );
     488                    $items[] = false !== has_filter( 'script_loader_src', [ self::class, 'mask_core_version' ] ) && ! str_contains( $masked, $version )
     489                        ? [ 'status' => 'passed', 'label' => 'hide_wp_version:mask_asset_versions' ]
     490                        : [ 'status' => 'failed', 'label' => 'hide_wp_version:mask_asset_versions', 'reason' => 'asset URLs still carry the WordPress version.' ];
     491                }
     492                if ( ! empty( $config['remove_readme'] ) ) {
     493                    $items[] = ! file_exists( ABSPATH . 'readme.html' )
     494                        ? [ 'status' => 'passed', 'label' => 'hide_wp_version:remove_readme' ]
     495                        : [ 'status' => 'failed', 'label' => 'hide_wp_version:remove_readme', 'reason' => 'readme.html is still in the site root; WordPress could not delete it (file permissions). Delete it by hand or deny it at the web server.' ];
     496                }
     497                return $items;
     498            },
     499        ] );
     500    }
     501
     502    /** init: wiring for remove_core_readme(). */
     503    public static function maybe_remove_core_readme(): void {
     504        self::remove_core_readme( ABSPATH . 'readme.html', (string) get_bloginfo( 'version' ) );
     505    }
     506
     507    /**
     508     * Delete $path once per WordPress version. Every core update (one-click,
     509     * WP-CLI or files copied by hand) puts readme.html back and changes the
     510     * version, so keying on the version catches all of them for the price of
     511     * one autoloaded option read per request, not a file stat. A failed
     512     * delete is not recorded, so it is retried. Public for unit testing.
     513     */
     514    public static function remove_core_readme( string $path, string $version ): bool {
     515        if ( get_option( self::README_OPTION ) === $version ) {
     516            return false;
     517        }
     518        if ( file_exists( $path ) ) {
     519            wp_delete_file( $path );
     520        }
     521        if ( file_exists( $path ) ) {
     522            return false;
     523        }
     524        update_option( self::README_OPTION, $version, true );
     525        return true;
     526    }
     527
     528    /** pre_option_elementor_meta_generator_tag: '1' is Elementor's "disabled". */
     529    public static function elementor_generator_off(): string {
     530        return '1';
     531    }
     532
     533    /**
     534     * script_loader_src / style_loader_src: swap ver=<WordPress version> for a
     535     * salted hash of it. Assets enqueued without an explicit version get the
     536     * WordPress version by default, so they are masked too. Other versions
     537     * (a plugin's own) are left alone. Public for unit testing.
     538     */
     539    public static function mask_core_version( mixed $src ): mixed {
     540        if ( ! is_string( $src ) || '' === $src ) {
     541            return $src;
     542        }
     543        $version = get_bloginfo( 'version' );
     544        parse_str( (string) wp_parse_url( $src, PHP_URL_QUERY ), $query );
     545        if ( ( $query['ver'] ?? null ) !== $version ) {
     546            return $src;
     547        }
     548        return add_query_arg( 'ver', substr( wp_hash( $version ), 0, 8 ), $src );
     549    }
    88550}
  • clautron/tags/1.5.0/includes/primitives/class-primitive-sitemap.php

    r3691441 r3784837  
    3030            'category'    => 'frontend',
    3131            'phase'       => 'compile',
     32            'effects'     => [],
    3233            'safe'        => false,
    3334            'label'       => 'Register XML Sitemap',
  • clautron/tags/1.5.0/includes/primitives/class-primitive-taxonomy.php

    r3582869 r3784837  
    2828            'category'    => 'taxonomy',
    2929            'phase'       => 'runtime',
     30            'effects'     => [ 'writes_database' ],
    3031            'label'       => 'Set Post Terms',
    3132            'description' => 'Assign taxonomy terms to a post. Accepts term IDs, slugs, or names.',
    … …  
    7273            'category'    => 'taxonomy',
    7374            'phase'       => 'runtime',
     75            'effects'     => [],
    7476            'label'       => 'Get Post Terms',
    7577            'description' => 'Get all terms assigned to a post for a given taxonomy.',
    … …  
    117119            'category'    => 'taxonomy',
    118120            'phase'       => 'runtime',
     121            'effects'     => [ 'writes_database' ],
    119122            'label'       => 'Create Term',
    120123            'description' => 'Create a new taxonomy term if it does not already exist. Returns the term ID.',
    … …  
    176179            'category'    => 'taxonomy',
    177180            'phase'       => 'runtime',
     181            'effects'     => [],
    178182            'label'       => 'Term Exists',
    179183            'description' => 'Check if a term exists in a taxonomy. Writes term ID (int) or 0 if not found.',
  • clautron/tags/1.5.0/includes/primitives/class-primitive-transform.php

    r3582869 r3784837  
    6161            'category'    => 'data',
    6262            'phase'       => 'runtime',
     63            'effects'     => [],
    6364            'label'       => 'Parse JSON',
    6465            'description' => 'Decode a JSON string into an associative array and store it in a context variable.',
    … …  
    9091            'category'    => 'data',
    9192            'phase'       => 'runtime',
     93            'effects'     => [],
    9294            'label'       => 'To JSON',
    9395            'description' => 'Encode a context variable as a JSON string and store it in another variable.',
    … …  
    119121            'category'    => 'data',
    120122            'phase'       => 'runtime',
     123            'effects'     => [],
    121124            'label'       => 'Map Fields',
    122125            'description' => 'Remap an array of objects to a new shape. Each output key\'s value is a source field name or a {{expr}} template evaluated per item.',
    … …  
    168171            'category'    => 'data',
    169172            'phase'       => 'runtime',
     173            'effects'     => [],
    170174            'label'       => 'Filter Array',
    171175            'description' => 'Filter an array of objects by a field condition. Supported operators: eq, neq, gt, gte, lt, lte, contains, not_contains, empty, not_empty.',
    … …  
    214218            'category'    => 'data',
    215219            'phase'       => 'runtime',
     220            'effects'     => [],
    216221            'label'       => 'Array Pluck',
    217222            'description' => 'Extract a single field from each item in an array, producing a flat array of values.',
    … …  
    256261            'category'    => 'data',
    257262            'phase'       => 'runtime',
     263            'effects'     => [],
    258264            'label'       => 'Array Merge',
    259265            'description' => 'Merge multiple array variables into one combined array.',
    … …  
    290296            'category'    => 'data',
    291297            'phase'       => 'runtime',
     298            'effects'     => [],
    292299            'label'       => 'Array Unique',
    293300            'description' => 'Remove duplicate values from a flat array and reindex.',
    … …  
    319326            'category'    => 'data',
    320327            'phase'       => 'runtime',
     328            'effects'     => [],
    321329            'label'       => 'Array Sort',
    322330            'description' => 'Sort an array. If field is set, sort an array of objects by that field. Otherwise sort a flat array.',
    … …  
    375383            'category'    => 'data',
    376384            'phase'       => 'runtime',
     385            'effects'     => [],
    377386            'label'       => 'Array Slice',
    378387            'description' => 'Return a portion of an array starting at offset for a given length.',
    … …  
    408417            'category'    => 'data',
    409418            'phase'       => 'runtime',
     419            'effects'     => [],
    410420            'label'       => 'Count Array',
    411421            'description' => 'Count the number of items in an array variable and store the integer.',
    … …  
    437447            'category'    => 'data',
    438448            'phase'       => 'runtime',
     449            'effects'     => [],
    439450            'label'       => 'String Format',
    440451            'description' => 'Render a template string with {{variable}} placeholders and store the result.',
    … …  
    465476            'category'    => 'data',
    466477            'phase'       => 'runtime',
     478            'effects'     => [],
    467479            'label'       => 'String Truncate',
    468480            'description' => 'Truncate a string to a maximum character length, appending a suffix when truncated.',
    … …  
    501513            'category'    => 'data',
    502514            'phase'       => 'runtime',
     515            'effects'     => [],
    503516            'label'       => 'String Replace',
    504517            'description' => 'Find and replace a substring within a string.',
    … …  
    533546            'category'    => 'data',
    534547            'phase'       => 'runtime',
     548            'effects'     => [],
    535549            'label'       => 'String Split',
    536550            'description' => 'Split a string by a delimiter into an array. Empty elements are removed.',
    … …  
    564578            'category'    => 'data',
    565579            'phase'       => 'runtime',
     580            'effects'     => [],
    566581            'label'       => 'String Join',
    567582            'description' => 'Join an array of strings into a single string using a glue separator.',
    … …  
    595610            'category'    => 'data',
    596611            'phase'       => 'runtime',
     612            'effects'     => [],
    597613            'label'       => 'Date Format',
    598614            'description' => 'Format a date string or timestamp using a PHP date format string.',
    … …  
    626642            'category'    => 'data',
    627643            'phase'       => 'runtime',
     644            'effects'     => [],
    628645            'label'       => 'Date Diff',
    629646            'description' => 'Calculate the absolute difference between two dates in the requested unit.',
    … …  
    675692            'category'    => 'data',
    676693            'phase'       => 'runtime',
     694            'effects'     => [],
    677695            'label'       => 'Date Add',
    678696            'description' => 'Add a specified amount of time to a date and format the result.',
    … …  
    719737            'category'    => 'data',
    720738            'phase'       => 'runtime',
     739            'effects'     => [],
    721740            'label'       => 'Slugify',
    722741            'description' => 'Convert a string to a URL-friendly slug using WordPress sanitize_title().',
    … …  
    747766            'category'    => 'data',
    748767            'phase'       => 'runtime',
     768            'effects'     => [],
    749769            'label'       => 'Math Calculate',
    750770            'description' => 'Perform a numeric math operation between two values.',
  • clautron/tags/1.5.0/includes/primitives/class-primitive-ui.php

    r3691441 r3784837  
    4141            'category'    => 'ui',
    4242            'phase'       => 'compile',
     43            'effects'     => [ 'writes_database' ],
    4344            'label'       => 'Add Metabox',
    4445            'description' => 'Add a custom metabox panel to the post edit screen for one or more post types.',
    … …  
    109110                }
    110111            },
     112            'verify' => static function ( array $config ): array {
     113                $box_id = sanitize_key( $config['id'] ?? '' );
     114                if ( ! $box_id ) {
     115                    return [];
     116                }
     117                // Handler registers with the 'clautron-' prefix (see above) —
     118                // the check must look for the same id it was actually
     119                // registered under.
     120                $mbid = 'clautron-' . $box_id;
     121                $pts  = (array) ( $config['post_types'] ?? [ 'post' ] );
     122                $pt   = $pts[0] ?? 'post';
     123                if ( ! is_admin() ) {
     124                    return [ [ 'status' => 'warning', 'label' => "add_metabox:{$mbid}", 'reason' => 'skipped (requires admin context; will work correctly on post edit screens).' ] ];
     125                }
     126                $hook_name = 'add_meta_boxes_' . $pt;
     127                do_action( $hook_name, null );
     128                do_action( 'add_meta_boxes', $pt, null );
     129                global $wp_meta_boxes;
     130                $found = false;
     131                foreach ( $wp_meta_boxes[ $pt ] ?? [] as $context_boxes ) {
     132                    foreach ( $context_boxes as $priority_boxes ) {
     133                        if ( isset( $priority_boxes[ $mbid ] ) ) {
     134                            $found = true;
     135                            break 2;
     136                        }
     137                    }
     138                }
     139                if ( $found ) {
     140                    return [ [ 'status' => 'passed', 'label' => "add_metabox:{$mbid}" ] ];
     141                }
     142                return [ [ 'status' => 'failed', 'label' => "add_metabox:{$mbid}", 'reason' => "not found in wp_meta_boxes for post type '{$pt}'." ] ];
     143            },
    111144        ] );
    112145    }
    … …  
    118151            'category'    => 'ui',
    119152            'phase'       => 'compile',
     153            'effects'     => [],
    120154            'label'       => 'Add Admin Column',
    121155            'description' => 'Add a custom column to the WordPress admin list table for a post type.',
    … …  
    215249                }
    216250            },
     251            'verify' => static function ( array $config ): array {
     252                $pts   = (array) ( $config['post_types'] ?? [] );
     253                $items = [];
     254                foreach ( $pts as $pt ) {
     255                    $filter = "manage_{$pt}_posts_columns";
     256                    if ( has_filter( $filter ) ) {
     257                        $items[] = [ 'status' => 'passed', 'label' => "add_admin_column:{$pt}" ];
     258                    } else {
     259                        $items[] = [ 'status' => 'failed', 'label' => "add_admin_column:{$pt}", 'reason' => "filter '{$filter}' not hooked." ];
     260                    }
     261                }
     262                return $items;
     263            },
    217264        ] );
    218265    }
    … …  
    224271            'category'    => 'ui',
    225272            'phase'       => 'compile',
     273            'effects'     => [],
    226274            'label'       => 'Add Admin Filter',
    227275            'description' => 'Add a custom dropdown filter to the WordPress admin post list table.',
    … …  
    325373            'category'    => 'ui',
    326374            'phase'       => 'runtime',
     375            'effects'     => [ 'executes_code' ],
     376            'safe'        => false,
    327377            'label'       => 'Add Admin Notice',
    328378            'description' => 'Display an admin notice (info, success, warning, error).',
    … …  
    359409            'category'    => 'ui',
    360410            'phase'       => 'compile',
     411            'effects'     => [ 'writes_database' ],
    361412            'label'       => 'Add Bulk Action',
    362413            'description' => 'Add a custom bulk action to a post type list table.',
    … …  
    423474            'category'    => 'ui',
    424475            'phase'       => 'compile',
     476            'effects'     => [],
    425477            'label'       => 'Add Report Page',
    426478            'description' => 'Add a stats/report section to the Clautron Reports admin page. Renders metric cards with post counts based on configurable queries.',
    … …  
    9861038            'category'    => 'ui',
    9871039            'phase'       => 'compile',
     1040            'effects'     => [ 'writes_database' ],
    9881041            'label'       => 'Add Row Action',
    9891042            'description' => 'Add a custom action link to post row actions in the list table (e.g. Clone).',
    … …  
    10411094                } );
    10421095            },
     1096            'verify' => static function ( array $config ): array {
     1097                $pts   = (array) ( $config['post_types'] ?? [] );
     1098                $items = [];
     1099                foreach ( $pts as $pt ) {
     1100                    if ( has_filter( 'post_row_actions' ) || has_filter( "{$pt}_row_actions" ) ) {
     1101                        $items[] = [ 'status' => 'passed', 'label' => "add_row_action:{$pt}" ];
     1102                    } else {
     1103                        $items[] = [ 'status' => 'warning', 'label' => "add_row_action:{$pt}", 'reason' => 'row action filter not detected (may still work).' ];
     1104                    }
     1105                }
     1106                return $items;
     1107            },
    10431108        ] );
    10441109    }
    … …  
    11221187            'category'    => 'ui',
    11231188            'phase'       => 'compile',
     1189            'effects'     => [ 'writes_database' ],
    11241190            'label'       => 'Add Options Page',
    11251191            'description' => 'Add a site-wide settings page under wp-admin (not tied to any single post). Uses the same field types as add_metabox\'s fields template: text, textarea, checkbox, select, radio, image, repeater. Each field is saved as its own option: clautron_opt_{menu_slug}_{field_key}.',
  • clautron/tags/1.5.0/includes/primitives/class-primitive-user.php

    r3582869 r3784837  
    3232            'category'    => 'user',
    3333            'phase'       => 'runtime',
     34            'effects'     => [],
    3435            'label'       => 'Read User Meta',
    3536            'description' => 'Read a user meta field and store the value in a context variable.',
    … …  
    6263            'category'    => 'user',
    6364            'phase'       => 'runtime',
     65            'effects'     => [ 'writes_database' ],
    6466            'label'       => 'Write User Meta',
    6567            'description' => 'Write or update a user meta value. All fields support {{variable}} syntax.',
    … …  
    103105            'category'    => 'user',
    104106            'phase'       => 'runtime',
     107            'effects'     => [ 'writes_database' ],
    105108            'label'       => 'Update User Role',
    106109            'description' => 'Change a user\'s WordPress role. Allowed roles: subscriber, contributor, author, editor. Cannot promote to administrator.',
    … …  
    152155            'category'    => 'user',
    153156            'phase'       => 'runtime',
     157            'effects'     => [ 'writes_database', 'deletes_data' ],
    154158            'label'       => 'Delete User (Safe)',
    155159            'description' => 'Delete a WordPress user. By default, skipped if the user has any published posts or approved comments. Set force=true to override.',
    … …  
    230234            'category'    => 'user',
    231235            'phase'       => 'runtime',
     236            'effects'     => [],
    232237            'label'       => 'Get Current User',
    233238            'description' => 'Store the currently logged-in user\'s data (id, login, email, display_name, roles) in a context variable.',
    … …  
    270275            'category'    => 'user',
    271276            'phase'       => 'runtime',
     277            'effects'     => [],
    272278            'label'       => 'Check User Capability',
    273279            'description' => 'Check if a user has a specific capability. Writes true/false to output_variable.',
    … …  
    305311            'category'    => 'user',
    306312            'phase'       => 'runtime',
     313            'effects'     => [],
    307314            'label'       => 'Check User Role',
    308315            'description' => 'Check if a user has a specific role. Writes true/false to output_variable.',
  • clautron/tags/1.5.0/includes/primitives/class-primitive-webhook.php

    r3691441 r3784837  
    3838            'category'    => 'http',
    3939            'phase'       => 'compile',
     40            'effects'     => [ 'writes_database', 'changes_schema' ],
    4041            'safe'        => false,
    4142            'label'       => 'Register Payment Webhook',
  • clautron/tags/1.5.0/includes/primitives/class-primitive-woocommerce.php

    r3691441 r3784837  
    3636            'category'    => 'woocommerce',
    3737            'phase'       => 'runtime',
     38            'effects'     => [],
    3839            'label'       => 'Query Orders',
    3940            'description' => 'Query WooCommerce orders with filters. Returns array of orders (id, status, total, currency, customer_id, customer_email, date_created, items_count) stored in a context variable.',
    … …  
    109110            'category'    => 'woocommerce',
    110111            'phase'       => 'runtime',
     112            'effects'     => [],
    111113            'label'       => 'Query Products',
    112114            'description' => 'Query WooCommerce products with filters. Returns array of products (id, name, sku, price, regular_price, stock_status, stock_quantity, type, status) stored in a context variable.',
    … …  
    180182            'category'    => 'woocommerce',
    181183            'phase'       => 'runtime',
     184            'effects'     => [ 'writes_database', 'external_requests' ],
    182185            'label'       => 'Update Order Status',
    183186            'description' => 'Move a WooCommerce order to another standard status (pending, processing, on-hold, completed, cancelled, refunded, failed). Adds an order note attributing the change to the Clautron workflow. Trashing/deleting orders is intentionally not supported.',
  • clautron/tags/1.5.0/includes/primitives/class-primitive-wordpress.php

    r3691441 r3784837  
    4646            'category'    => 'wordpress',
    4747            'phase'       => 'compile',
     48            // A custom capability_type grants its caps to administrator, which
     49            // WordPress stores in wp_user_roles: that outlives deactivation.
     50            'effects'     => static fn( array $c ): array => in_array( trim( explode( ',', (string) ( $c['capability_type'] ?? 'post' ) )[0] ), [ 'post', 'page' ], true ) ? [ 'changes_schema' ] : [ 'changes_schema', 'writes_database' ],
     51            'effects_max' => [ 'changes_schema', 'writes_database' ],
    4852            'label'       => 'Register Custom Post Type',
    4953            'description' => 'Register a new custom post type in WordPress.',
    … …  
    7882                } );
    7983            },
     84            'verify' => static function ( array $config ): array {
     85                $pt = $config['post_type'] ?? '';
     86                if ( ! $pt ) {
     87                    return [];
     88                }
     89                if ( get_post_type_object( $pt ) ) {
     90                    return [ [ 'status' => 'passed', 'label' => "register_cpt:{$pt}" ] ];
     91                }
     92                return [ [ 'status' => 'failed', 'label' => "register_cpt:{$pt}", 'reason' => 'post type not registered.' ] ];
     93            },
    8094        ] );
    8195    }
    … …  
    89103     * type they just asked for. Grant the mapped capabilities to
    90104     * administrator; add_cap() is idempotent, safe to call every request.
     105     *
     106     * Only primitive capabilities. edit_post, read_post and delete_post are
     107     * meta capabilities that map_meta_cap() resolves per post; a role must
     108     * never hold them literally. WP_Role::has_cap() can't see them either,
     109     * so granting them would rewrite wp_user_roles on every request, even
     110     * for capability_type "post".
    91111     */
    92112    public static function grant_cpt_capabilities_to_admin( \WP_Post_Type $post_type_obj ): void {
    … …  
    95115            return;
    96116        }
    97         foreach ( (array) $post_type_obj->cap as $cap ) {
     117        $primitive = array_diff_key( (array) $post_type_obj->cap, array_flip( [ 'edit_post', 'read_post', 'delete_post' ] ) );
     118        foreach ( $primitive as $cap ) {
    98119            if ( is_string( $cap ) && ! $role->has_cap( $cap ) ) {
    99120                $role->add_cap( $cap );
    100121            }
    101122        }
     123    }
     124
     125    /**
     126     * Effects of giving a post $status: trash removes it from the site, and
     127     * publishing makes WordPress ping (update services, pingbacks,
     128     * enclosures). An empty status leaves it unchanged. A value with a
     129     * {{placeholder}} is only known at run time (and sanitize_key() turns
     130     * "{{trash}}" into "trash"), so it gets the worst case. Core re-pinging
     131     * when an already published post is saved again is not counted.
     132     */
     133    public static function post_status_effects( string $status ): array {
     134        if ( str_contains( $status, '{{' ) ) {
     135            return [ 'writes_database', 'deletes_data', 'external_requests' ];
     136        }
     137        return [
     138            'trash'   => [ 'writes_database', 'deletes_data' ],
     139            'publish' => [ 'writes_database', 'external_requests' ],
     140        ][ sanitize_key( $status ) ] ?? [ 'writes_database' ];
    102141    }
    103142
    … …  
    142181            'category'    => 'wordpress',
    143182            'phase'       => 'compile',
     183            'effects'     => [ 'changes_schema' ],
    144184            'label'       => 'Register Taxonomy',
    145185            'description' => 'Register a custom taxonomy for one or more post types.',
    … …  
    167207                    register_taxonomy( $taxonomy, $post_types, self::build_taxonomy_args( $config, $label, $taxonomy ) );
    168208                } );
     209            },
     210            'verify' => static function ( array $config ): array {
     211                $tax = $config['taxonomy'] ?? '';
     212                if ( ! $tax ) {
     213                    return [];
     214                }
     215                if ( taxonomy_exists( $tax ) ) {
     216                    return [ [ 'status' => 'passed', 'label' => "register_taxonomy:{$tax}" ] ];
     217                }
     218                return [ [ 'status' => 'failed', 'label' => "register_taxonomy:{$tax}", 'reason' => 'taxonomy not registered.' ] ];
    169219            },
    170220        ] );
    … …  
    194244            'category'    => 'wordpress',
    195245            'phase'       => 'compile',
     246            'effects'     => [ 'changes_schema' ],
    196247            'label'       => 'Register Meta Field',
    197248            'description' => 'Register a meta field for posts, users, or terms with type and schema.',
    … …  
    231282                } );
    232283            },
     284            'verify' => static function ( array $config ): array {
     285                $key = $config['meta_key'] ?? '';
     286                if ( ! $key ) {
     287                    return [];
     288                }
     289                $pts        = (array) ( $config['post_types'] ?? [ 'post' ] );
     290                $registered = registered_meta_key_exists( 'post', $key, $pts[0] ?? '' );
     291                if ( $registered ) {
     292                    return [ [ 'status' => 'passed', 'label' => "register_meta:{$key}" ] ];
     293                }
     294                return [ [ 'status' => 'warning', 'label' => "register_meta:{$key}", 'reason' => 'not found in registered meta (may still work via update_post_meta).' ] ];
     295            },
    233296        ] );
    234297    }
    … …  
    240303            'category'    => 'wordpress',
    241304            'phase'       => 'compile',
     305            'effects'     => [ 'changes_schema' ],
    242306            'safe'        => false,
    243307            'label'       => 'Register REST Route',
    … …  
    286350                } );
    287351            },
     352            'verify' => static function ( array $config ): array {
     353                $route = $config['route'] ?? '';
     354                if ( ! $route ) {
     355                    return [];
     356                }
     357                $routes    = rest_get_server()->get_routes();
     358                $full_path = '/clautron/v1/' . ltrim( $route, '/' );
     359                if ( isset( $routes[ $full_path ] ) ) {
     360                    return [ [ 'status' => 'passed', 'label' => "register_rest_route:{$route}" ] ];
     361                }
     362                return [ [ 'status' => 'warning', 'label' => "register_rest_route:{$route}", 'reason' => 'not found in registered routes (REST server may not be initialized yet).' ] ];
     363            },
    288364        ] );
    289365    }
    … …  
    295371            'category'    => 'wordpress',
    296372            'phase'       => 'compile',
     373            'effects'     => [ 'writes_database' ],
    297374            'label'       => 'Schedule Post Expiry',
    298375            'description' => 'Automatically change post status on its expiry date (stored in meta).',
    … …  
    353430            'category'    => 'wordpress',
    354431            'phase'       => 'runtime',
     432            // The handler only allows draft/publish/pending/private (anything else
     433            // becomes draft), so a new post can be published but never trashed.
     434            'effects'     => static fn( array $c ): array => array_values( array_diff( self::post_status_effects( (string) ( $c['post_status'] ?? 'draft' ) ), [ 'deletes_data' ] ) ),
     435            'effects_max' => [ 'writes_database', 'external_requests' ],
     436            'safe'        => false,
    355437            'label'       => 'Create Post',
    356438            'description' => 'Create a new WordPress post (any type / status). Supports deduplication by post meta key + value to avoid creating duplicate posts across job runs.',
    … …  
    450532            'category'    => 'wordpress',
    451533            'phase'       => 'runtime',
     534            'effects'     => static fn( array $c ): array => self::post_status_effects( (string) ( $c['post_status'] ?? '' ) ),
     535            'effects_max' => [ 'writes_database', 'deletes_data', 'external_requests' ],
    452536            'label'       => 'Update Post',
    453537            'description' => 'Update fields on an existing WordPress post. Only provided fields are changed.',
    … …  
    523607            'category'    => 'wordpress',
    524608            'phase'       => 'runtime',
     609            'effects'     => [ 'writes_database', 'deletes_data' ],
    525610            'label'       => 'Delete Post',
    526611            'description' => 'Move a post to trash (force=false) or permanently delete it (force=true).',
    … …  
    562647            'category'    => 'wordpress',
    563648            'phase'       => 'runtime',
     649            'effects'     => static fn( array $c ): array => self::post_status_effects( (string) ( $c['status'] ?? '' ) ),
     650            'effects_max' => [ 'writes_database', 'deletes_data', 'external_requests' ],
    564651            'label'       => 'Set Post Status',
    565652            'description' => 'Change the status of a post (draft, publish, pending, private, trash).',
    … …  
    602689            'category'    => 'wordpress',
    603690            'phase'       => 'runtime',
     691            'effects'     => [],
    604692            'label'       => 'Check WooCommerce HPOS',
    605693            'description' => 'Detects whether WooCommerce High Performance Order Storage (HPOS) is active. Stores true/false in a context variable. Use with if_else to branch between HPOS and legacy wp_posts order queries.',
    … …  
    625713            'category'    => 'wordpress',
    626714            'phase'       => 'runtime',
     715            'effects'     => [ 'writes_database' ],
    627716            'label'       => 'Schedule Single Event',
    628717            'description' => 'Schedule a one-time WP-Cron event to fire at a future time. The hook name must start with "clautron_" for safety. The arg is resolved as an integer (post/order ID).',
  • clautron/tags/1.5.0/includes/primitives/class-primitive-workflow.php

    r3582869 r3784837  
    2828            'category'    => 'workflow',
    2929            'phase'       => 'runtime',
     30            'effects'     => [],
    3031            'label'       => 'Validate Field',
    3132            'description' => 'Validate a value against rules (required, min_length, max_length, email, url, numeric, regex). Writes true/false to output_variable. Writes error message to error_variable if set.',
    … …  
    105106            'category'    => 'workflow',
    106107            'phase'       => 'runtime',
     108            'effects'     => [ 'writes_database' ],
    107109            'label'       => 'Request Review',
    108110            'description' => 'Flag a post for editorial review by setting its status to \'pending\' and storing a review request note in meta.',
    … …  
    144146            'category'    => 'workflow',
    145147            'phase'       => 'runtime',
     148            'effects'     => [ 'writes_database', 'deletes_data', 'external_requests' ], // deletes the review note; publishing pings
    146149            'label'       => 'Approve Content',
    147150            'description' => 'Approve content by publishing a pending post and clearing the review note.',
    … …  
    177180            'category'    => 'workflow',
    178181            'phase'       => 'runtime',
     182            'effects'     => [ 'writes_database' ],
    179183            'label'       => 'Reject Content',
    180184            'description' => 'Reject content by reverting a pending post to draft and storing a rejection reason.',
  • clautron/tags/1.5.0/includes/registry/class-primitive-registry.php

    r3582869 r3784837  
    3636     *   @type string   $description One-sentence description for the AI prompt catalog.
    3737     *   @type string   $dependency  null | 'woocommerce' | 'acf' | ...
     38     *   @type callable|null $verify function( array $config ): array — used by
     39     *                               CLAUTRON_Smoke_Tester. Returns zero or more
     40     *                               check items { status: passed|failed|warning,
     41     *                               label: string, reason?: string }. Return []
     42     *                               when there's nothing meaningful to check for
     43     *                               a given config (e.g. a required field is
     44     *                               empty). Omit entirely (leave null) if this
     45     *                               primitive type has no verification yet — it
     46     *                               will show up as 'unavailable' rather than
     47     *                               being silently skipped.
     48     *   @type string[]|Closure|null $effects  Which of self::EFFECTS the primitive
     49     *                               has, e.g. [ 'writes_database' ]; [] = none.
     50     *                               A Closure( array $config ): string[] when
     51     *                               they depend on config. null (omitted) =
     52     *                               undeclared, which consumers must treat as
     53     *                               "could be anything". An unknown effect name
     54     *                               is reported with _doing_it_wrong() and makes
     55     *                               the primitive undeclared, never "no effect".
     56     *   @type string[] $effects_max Required with a Closure: every effect any
     57     *                               config can produce. The catalog publishes
     58     *                               this worst case; the abilities still answer
     59     *                               per call from the real config.
     60     *   @type bool|Closure|null $reversible  Whether deactivating removes every
     61     *                               trace. Omitted = derived from effects (no
     62     *                               data written, deleted or sent away). Set it
     63     *                               when something outlives deactivation that
     64     *                               effects can't express (a cookie, HSTS).
     65     *   @type array[]  $examples    Example configs. Omitted = the
     66     *                               clautron/primitives-list ability falls back
     67     *                               to configs found in the shipped demos.
    3868     * }
    3969     */
    … …  
    5383            'description' => '',
    5484            'dependency'  => null,
     85            'verify'      => null,
     86            'effects'     => null,
     87            'effects_max' => null,
     88            'reversible'  => null,
     89            'examples'    => [],
    5590        ] );
    5691
    5792        self::$primitives[ $type ]['type'] = $type;
     93        self::check_effects( $type );
     94    }
     95
     96    /**
     97     * A typo in an effect name must not quietly read as "no effect" (and so
     98     * as reversible): report it and treat the primitive as undeclared.
     99     */
     100    private static function check_effects( string $type ): void {
     101        $def     = self::$primitives[ $type ];
     102        $unknown = [];
     103        foreach ( [ 'effects', 'effects_max' ] as $key ) {
     104            if ( is_array( $def[ $key ] ) ) {
     105                $unknown = array_merge( $unknown, array_diff( $def[ $key ], self::EFFECTS ) );
     106            }
     107        }
     108        if ( $unknown ) {
     109            self::$primitives[ $type ]['effects'] = null;
     110            self::doing_it_wrong( "Primitive '{$type}' declares unknown effects: " . implode( ', ', array_unique( $unknown ) ) . '. Allowed: ' . implode( ', ', self::EFFECTS ) . '.' );
     111        } elseif ( $def['effects'] instanceof Closure && ! is_array( $def['effects_max'] ) ) {
     112            self::doing_it_wrong( "Primitive '{$type}' computes its effects from config but declares no effects_max; the catalog will list its effects as unknown." );
     113        }
     114    }
     115
     116    private static function doing_it_wrong( string $message ): void {
     117        if ( function_exists( '_doing_it_wrong' ) ) {
     118            _doing_it_wrong( 'CLAUTRON_Primitive_Registry::register', esc_html( $message ), '1.5.0' );
     119        }
     120    }
     121
     122    /**
     123     * Effect vocabulary, the same keys a capability manifest's `effects` uses
     124     * (Capability Contract v1), so a consumer reads both the same way:
     125     *   writes_database    creates or changes content, meta, options, users,
     126     *                      terms, comments, orders, events or transients
     127     *   writes_files       writes or deletes files on disk
     128     *   deletes_data       removes or trashes site data
     129     *   external_requests  sends data off the site (HTTP, email, AI provider),
     130     *                      including what WordPress itself sends because of
     131     *                      it: pings when a post gets published, the post
     132     *                      author's email when a comment is approved
     133     *   executes_code      outputs markup or script taken from its config
     134     *   changes_schema     changes the content model or API surface (post
     135     *                      types, taxonomies, registered meta, REST routes)
     136     */
     137    public const EFFECTS = [ 'writes_database', 'writes_files', 'deletes_data', 'external_requests', 'executes_code', 'changes_schema' ];
     138
     139    /** Effects that make deactivation an incomplete undo when nothing else is declared. */
     140    private const PERSISTENT_EFFECTS = [ 'writes_database', 'writes_files', 'deletes_data', 'external_requests' ];
     141
     142    /**
     143     * Effects of one node, as EFFECTS => bool, or null when the primitive
     144     * (or the type itself) is unknown or declares none.
     145     */
     146    public static function node_effects( string $type, array $config = [] ): ?array {
     147        $def = self::get( $type );
     148        if ( ! $def || null === $def['effects'] ) {
     149            return null;
     150        }
     151        $list = $def['effects'] instanceof Closure ? (array) ( $def['effects'] )( $config ) : (array) $def['effects'];
     152        if ( array_diff( $list, self::EFFECTS ) ) {
     153            self::doing_it_wrong( "Primitive '{$type}' computed unknown effects: " . implode( ', ', array_diff( $list, self::EFFECTS ) ) . '.' );
     154            return null;
     155        }
     156        return array_combine( self::EFFECTS, array_map( static fn( string $e ): bool => in_array( $e, $list, true ), self::EFFECTS ) );
     157    }
     158
     159    /**
     160     * Every effect the primitive can have with any config: effects_max for a
     161     * config-dependent declaration, the static list otherwise. null when
     162     * undeclared or when a Closure has no effects_max.
     163     *
     164     * @return string[]|null
     165     */
     166    public static function max_effects( string $type ): ?array {
     167        $def = self::get( $type );
     168        if ( ! $def || null === $def['effects'] ) {
     169            return null;
     170        }
     171        if ( $def['effects'] instanceof Closure ) {
     172            return is_array( $def['effects_max'] ) ? array_values( $def['effects_max'] ) : null;
     173        }
     174        return array_values( (array) $def['effects'] );
     175    }
     176
     177    /** Whether deactivating undoes the primitive with every config (false when any config can't). */
     178    private static function always_reversible( array $def, ?array $max ): bool {
     179        if ( null === $max || $def['reversible'] instanceof Closure ) {
     180            return false;
     181        }
     182        if ( null !== $def['reversible'] ) {
     183            return (bool) $def['reversible'];
     184        }
     185        return ! array_intersect( $max, self::PERSISTENT_EFFECTS );
     186    }
     187
     188    /**
     189     * Combined effects of every node in a blueprint (structures, conditions,
     190     * actions), whether deactivating it would undo all of them, and which
     191     * node types declare no effects (consumers should treat those as risky).
     192     *
     193     * @return array{effects: array<string,bool>, reversible: bool, undeclared: string[]}
     194     */
     195    public static function effects_of( array $blueprint ): array {
     196        $effects    = array_fill_keys( self::EFFECTS, false );
     197        $reversible = true;
     198        $undeclared = [];
     199
     200        foreach ( [ 'structures', 'conditions', 'actions' ] as $list ) {
     201            foreach ( (array) ( $blueprint[ $list ] ?? [] ) as $node ) {
     202                $type   = (string) ( $node['type'] ?? '' );
     203                $config = (array) ( $node['config'] ?? [] );
     204                $node_effects = self::node_effects( $type, $config );
     205                if ( null === $node_effects ) {
     206                    $undeclared[] = $type;
     207                    $reversible   = false;
     208                    continue;
     209                }
     210                foreach ( $node_effects as $effect => $has ) {
     211                    $effects[ $effect ] = $effects[ $effect ] || $has;
     212                }
     213                $reversible = $reversible && self::node_reversible( $type, $config, $node_effects );
     214            }
     215        }
     216
     217        return [ 'effects' => $effects, 'reversible' => $reversible, 'undeclared' => array_values( array_unique( $undeclared ) ) ];
     218    }
     219
     220    private static function node_reversible( string $type, array $config, array $effects ): bool {
     221        $declared = self::$primitives[ $type ]['reversible'];
     222        if ( $declared instanceof Closure ) {
     223            return (bool) $declared( $config );
     224        }
     225        if ( null !== $declared ) {
     226            return (bool) $declared;
     227        }
     228        return ! array_filter( array_intersect_key( $effects, array_flip( self::PERSISTENT_EFFECTS ) ) );
    58229    }
    59230
    … …  
    120291
    121292    /**
    122      * Build the capability catalog injected into the AI system prompt.
    123      * Returns a lightweight array — type, category, description, schema.
     293     * Build the capability catalog injected into the AI system prompt and
     294     * returned by the clautron/primitives-list ability.
     295     *
     296     * `safe` false = needs explicit approval before use; `verifiable` = the
     297     * primitive declares a verify check, so a smoke test can confirm it.
     298     * `effects` is the worst case over every config (null when undeclared or
     299     * unknown) and `reversible` is true only when deactivating undoes it with
     300     * every config; `effects_vary` = some config values are milder, so ask
     301     * effects_of() for the real config (the abilities return it per call).
    124302     *
    125303     * @return array[]
    … …  
    128306        $catalog = [];
    129307        foreach ( self::$primitives as $type => $def ) {
     308            $max       = self::max_effects( $type );
    130309            $catalog[] = [
    131                 'type'        => $type,
    132                 'category'    => $def['category'],
    133                 'phase'       => $def['phase'],
    134                 'label'       => $def['label'],
    135                 'description' => $def['description'],
    136                 'schema'      => $def['schema'],
    137                 'dependency'  => $def['dependency'],
     310                'type'         => $type,
     311                'category'     => $def['category'],
     312                'phase'        => $def['phase'],
     313                'label'        => $def['label'],
     314                'description'  => $def['description'],
     315                'schema'       => $def['schema'],
     316                'dependency'   => $def['dependency'],
     317                'safe'         => (bool) $def['safe'],
     318                'verifiable'   => is_callable( $def['verify'] ),
     319                'effects'      => null === $max ? null : array_values( array_intersect( self::EFFECTS, $max ) ),
     320                'effects_vary' => $def['effects'] instanceof Closure || $def['reversible'] instanceof Closure,
     321                'reversible'   => self::always_reversible( $def, $max ),
     322                'examples'     => array_values( (array) $def['examples'] ),
    138323            ];
    139324        }
  • clautron/tags/1.5.0/readme.txt

    r3691450 r3784837  
    33Tags: capability, automation, security, no-code, ai
    44Requires at least: 7.0
    5 Tested up to: 7.0
    6 Stable tag: 1.2.0
     5Tested up to: 7.1
     6Stable tag: 1.5.0
    77Requires PHP: 8.1
    88License: GPL-2.0-or-later
    … …  
    2121**One secure layer. Thousands of capabilities.**
    2222
    23 Every capability Clautron builds can be activated, deactivated, and rolled back the same predictable way. Before you turn one on, you can see what it reads, what it writes, what it connects to, and how much it changes.
     23Every capability Clautron builds can be activated and deactivated the same predictable way, without touching any data it wrote while it was on. Before you turn one on, you can see what it reads, what it writes, what it connects to, and how much it changes.
    2424
    2525**Key features:**
    … …  
    2828* **Search first, generate second:** Clautron checks what already exists before generating anything new; when it does generate something, you review it before it runs
    2929* **Know what runs on your site:** every capability is built from a declared, inspectable set of primitives
    30 * **Reversible by design:** activate, deactivate, or roll back any capability without digging through code
     30* **Deactivate without data loss:** turn any capability off without digging through code. Nothing it wrote while active is touched, and turning it back on reactivates the same capability instead of starting over
     31* **Machine-readable capability manifest:** each capability can declare its own version, requirements, required permissions, and effects (what it reads, writes, or connects to). External tools like AICOM can inspect and manage capabilities through this same contract, without hardcoded per-capability logic
     32* **Know what a capability does before you turn it on:** every building block declares whether it writes data, deletes data, sends data off the site or outputs code, and whether turning it off fully undoes it
     33* **Security hardening without a security plugin:** stop username discovery, add the missing security headers and hide the WordPress version, each as a small capability you can switch off again
     34* **Ready for AI agents:** every Clautron operation is a WordPress Ability with its own schema, so AI agents and tools like AICOM can find and use them without custom integration code
    3135* **Scheduled jobs and reports:** recurring automations and on-demand data queries, without a separate plugin for each
    3236* **Safe by construction:** a safety policy layer blocks dangerous operations before they run
    … …  
    5155= What are primitives, and what's a capability? =
    5256
    53 Primitives are the small, reusable building blocks Clautron composes things from: custom post types, meta fields, admin columns, scheduled tasks, and more. A capability is what you actually get: a specific piece of functionality (a redirect, an admin column, a WooCommerce rule) built from one or more primitives, that you can activate, deactivate, or roll back as one unit.
     57Primitives are the small, reusable building blocks Clautron composes things from: custom post types, meta fields, admin columns, scheduled tasks, and more. A capability is what you actually get: a specific piece of functionality (a redirect, an admin column, a WooCommerce rule) built from one or more primitives, that you can activate or deactivate as one unit. Some capabilities also carry a manifest (a declared version, requirements, and effects) that lets external tools install, inspect, update, and deactivate them without capability-specific code.
    5458
    5559= Does this plugin store data in the database? =
    5660
    57 Yes. Clautron creates its own database tables (prefixed with `wp_clautron_`) to store blueprints, conversation history, scheduled jobs, workflow state, and execution logs. All data is removed if you uninstall the plugin.
     61Yes. Clautron creates its own database tables (prefixed with `wp_clautron_`) to store blueprints, conversation history, scheduled jobs, workflow state, capability install state, and execution logs. All data is removed if you uninstall the plugin.
    5862
    5963= What permissions are required? =
    6064
    61 Clautron is accessible only to administrators (`manage_options` capability). No frontend functionality or public-facing pages are added to your site.
     65Clautron's own screens are only available to administrators (`manage_options` capability). Clautron adds nothing to the frontend by itself: visitors only see changes from capabilities you activate, such as a consent banner, a redirect or security headers.
    6266
    6367== External Services ==
    … …  
    7579
    7680== Changelog ==
     81
     82= 1.5.0 =
     83* Added harden_user_enumeration: stops anonymous visitors from discovering usernames. Logged-out requests to the /wp/v2/users REST endpoints get a 401 (logged-in users and the block editor keep working), every author archive returns a 404, the users sitemap is removed, oEmbed shows the site instead of the author, and the login form gives the same error for an unknown username and a wrong password.
     84* Added security_headers: sends Referrer-Policy, X-Content-Type-Options, X-Frame-Options and Permissions-Policy, plus Strict-Transport-Security (HSTS) on HTTPS when you opt in. Its check loads your homepage to confirm the headers actually reach visitors, so a page cache or CDN that drops them is reported.
     85* Added hide_wp_version: removes the WordPress, WooCommerce and Elementor generator tags from pages and feeds, and masks the WordPress version in asset URLs while keeping browser caches working. Optionally deletes readme.html, and deletes it again after each WordPress update.
     86* Added a ready-made Security Hardening capability built from these three primitives.
     87* Every Clautron operation (catalog, install, inspect, update, deactivate, blueprints, events) is now a WordPress Ability with its own input schema and behavior annotations, so AI agents and management tools such as AICOM can discover and call them without Clautron-specific code. New: primitive-apply, which applies a single primitive as its own capability, validated and verified.
     88* Every primitive now declares its effects: whether it writes data, writes files, deletes data, sends data off the site, outputs code from its configuration, or changes the content model, and whether turning it off fully undoes it. Installing, applying or ensuring a capability reports these effects, in a dry run too, together with the step that reverses the change.
     89* Fixed ensuring a deactivated custom capability creating a second copy of it: it is now reactivated.
     90* Fixed register_cpt writing the edit_post, read_post and delete_post meta capabilities into the administrator role on every page load.
     91* http_request, fetch_feed, create_post and add_admin_notice are now flagged as needing approval, like the other primitives that can send data off the site, delete data or output code.
     92
     93= 1.4.0 =
     94* Added consent_banner: a category-based cookie-consent banner (necessary/analytics/marketing) with an anonymous REST endpoint to record the visitor's choice, a full audit trail (who consented to what, when, from which source) in the events store, and automatic opt-out when the visitor's browser sends the Global Privacy Control (Sec-GPC) signal.
     95* Added gate_script_by_consent: outputs a script or HTML block only once its required consent category has been granted, reading the same cookie consent_banner writes.
     96* consent_banner can emit Google Consent Mode v2 signals (gtag consent default/update), mapping consent categories to Google's ad_storage/analytics_storage/ad_user_data/ad_personalization signals for GA4 and Ads.
     97* Fixed handle_upload's internal failure closure declaring a standalone `false` return type, valid only since PHP 8.2, which fataled on activation despite the plugin declaring Requires PHP 8.1.
     98
     99= 1.3.0 =
     100* Added a machine-readable capability manifest (version, platform/PHP/WordPress requirements, required permissions, declared effects, reversibility), kept separate from a capability's blueprint and joined only by capability_id. Lets external tools like AICOM install, inspect, update, and deactivate capabilities through one consistent contract instead of capability-specific code.
     101* Added a new `wp_clautron_capabilities` table tracking each capability's installed version and status separately from its blueprint. This enables comparing installed vs. available version and reinstalling a deactivated capability without losing its edit history.
     102* Added a deactivate operation: stops a capability from being re-applied on future requests without touching any data it wrote while active. Reinstalling afterward reactivates the same capability rather than creating a duplicate.
     103* Added an update operation: re-syncs an installed capability to its manifest's current declared version in place.
     104* Primitive verification (used to confirm a compiled capability is correctly wired into WordPress) is no longer a fixed, hardcoded checklist covering a handful of primitive types: each primitive now declares its own check, and any primitive without one is reported explicitly rather than silently skipped.
     105* Fixed the metabox verification check to look up the actual registered meta box ID (it was checking an ID that never matched, so it could never report success).
    77106
    78107= 1.2.0 =
    … …  
    132161== Upgrade Notice ==
    133162
     163= 1.5.0 =
     164Adds security hardening primitives, WordPress Abilities for every operation, and declared effects for every primitive. No breaking changes.
     165
     166= 1.3.0 =
     167Adds a capability manifest, deactivate/update operations, and per-primitive verification. No breaking changes.
     168
    134169= 1.1.0 =
    135170Adds Elementor and WordPress widget primitives. No breaking changes.
  • clautron/tags/1.5.0/uninstall.php

    r3582869 r3784837  
    1616
    1717delete_option( 'clautron_settings' );
     18delete_option( 'clautron_readme_removed_for' ); // hide_wp_version's remove_readme bookkeeping
    1819delete_transient( 'clautron_active_workflows' );
  • clautron/trunk/clautron.php

    r3691450 r3784837  
    44 * Plugin URI:        https://wordpress.org/plugins/clautron/
    55 * Description:       Get the feature. Not the plugin. Add exactly the WordPress capability you need: inspectable, secure, reversible.
    6  * Version:           1.2.0
     6 * Version:           1.5.0
    77 * Author:            Clautron
    88 * Author URI:        https://profiles.wordpress.org/dudaster/
    … …  
    2020// ─── Constants ───────────────────────────────────────────────────────────────
    2121
    22 define( 'CLAUTRON_VERSION',     '1.2.0' );
     22define( 'CLAUTRON_VERSION',     '1.5.0' );
    2323define( 'CLAUTRON_FILE',        __FILE__ );
    2424define( 'CLAUTRON_DIR',         plugin_dir_path( __FILE__ ) );
    2525define( 'CLAUTRON_URL',         plugin_dir_url( __FILE__ ) );
    26 define( 'CLAUTRON_DB_VERSION',  '4' );
     26define( 'CLAUTRON_DB_VERSION',  '5' );
    2727
    2828// ─── Autoloader ──────────────────────────────────────────────────────────────
    … …  
    121121        'primitive-cookie'     => 'includes/primitives/class-primitive-cookie.php',
    122122
     123        // Consent (cookie-consent banner + category-gated script output)
     124        'primitive-consent'    => 'includes/primitives/class-primitive-consent.php',
     125
    123126        // Language (multilingual query filtering)
    124127        'primitive-language'   => 'includes/primitives/class-primitive-language.php',
    … …  
    157160        'catalog'              => 'includes/class-catalog.php',
    158161
     162        // Capability Manifest (machine-readable contract: version, requirements, scopes, effects)
     163        'manifest-registry'    => 'includes/registry/class-manifest-registry.php',
     164        'capability-store'     => 'includes/class-capability-store.php',
     165
    159166        // Smoke Tester (verify compiled blueprints are wired correctly)
    160167        'smoke-tester'         => 'includes/class-smoke-tester.php',
     168
     169        // Abilities (every operation above as a self-describing WordPress Ability)
     170        'abilities'            => 'includes/class-abilities.php',
    161171
    162172        // Admin
  • clautron/trunk/demo/blueprint-cookie-consent.json

    r3691441 r3784837  
    22  "id": "f2a3b4c5-d6e7-4890-b123-c45678901bcd",
    33  "object_type": "feature",
    4   "name": "Cookie Consent Banner",
    5   "description": "Shows a GDPR-style cookie consent banner in the footer with an Accept link. Clicking it stores consent in a first-party cookie for 365 days, which also suppresses the banner on return visits. Replaces a basic cookie-notice plugin.",
     4  "name": "GDPR Consent Banner",
     5  "description": "Cookie-consent banner with per-category consent (necessary/analytics/marketing), a full audit trail of who consented to what and when, automatic opt-out when the visitor's browser sends Global Privacy Control (Sec-GPC), and Google Consent Mode v2 signals for GA4/Ads. Replaces a dedicated cookie-consent plugin for the common case.",
    66  "capability_id": "cookie-consent-banner",
    77  "provides": {},
    88  "status": "draft",
    99  "source": "manual",
    10   "nl_input": "Show a cookie consent banner on the site with an accept button that remembers the choice for a year.",
     10  "nl_input": "Show a GDPR cookie consent banner with analytics/marketing categories, honor Global Privacy Control automatically, and signal Google Consent Mode.",
    1111  "structures": [
    1212    {
    1313      "id": "s-banner",
    14       "type": "inject_wp_footer",
     14      "type": "consent_banner",
    1515      "config": {
    16         "template": "<div id=\"clautron-cookie-banner\" style=\"position:fixed;bottom:0;left:0;right:0;background:#222;color:#fff;padding:14px 20px;font-size:14px;z-index:9999;\">This site uses cookies to improve your experience. <a href=\"?clautron_action=accept_cookies\" style=\"color:#8ecdf7;\">Accept</a></div>",
    17         "suppress_if_cookie": "cookie_consent"
     16        "categories": [
     17          { "key": "necessary", "label": "Necessary", "description": "Required for the site to function.", "required": true },
     18          { "key": "analytics", "label": "Analytics", "description": "Helps us understand site usage." },
     19          { "key": "marketing", "label": "Marketing", "description": "Used for ad personalization." }
     20        ],
     21        "policy_version": "1",
     22        "banner_text": "This site uses cookies. Choose which categories you're OK with.",
     23        "position": "bottom",
     24        "days": 180,
     25        "endpoint_path": "consent",
     26        "honor_gpc": true,
     27        "consent_mode_map": {
     28          "analytics": ["analytics_storage"],
     29          "marketing": ["ad_storage", "ad_user_data", "ad_personalization"]
     30        }
     31      }
     32    },
     33    {
     34      "id": "s-analytics-script",
     35      "type": "gate_script_by_consent",
     36      "config": {
     37        "category": "analytics",
     38        "html": "<!-- Load your analytics script here — it only outputs once \"analytics\" consent is granted -->",
     39        "placement": "head"
    1840      }
    1941    }
    20   ],
    21   "trigger": { "type": "hook", "config": { "hook": "template_redirect" } },
    22   "conditions": [
    23     { "id": "c1", "type": "read_request_param", "config": { "source": "get", "param": "clautron_action", "output_variable": "action", "default": "" } },
    24     { "id": "c2", "type": "if_else", "config": { "field": "{{action}}", "operator": "eq", "value": "accept_cookies", "pass_branch": ["a1"], "fail_branch": [] } }
    25   ],
    26   "actions": [
    27     { "id": "a1", "type": "write_cookie", "config": { "name": "cookie_consent", "value": "accepted", "days": 365 } }
    2842  ]
    2943}
  • clautron/trunk/includes/class-catalog.php

    r3582869 r3784837  
    99 *  - Active capabilities (compiled + running on this site).
    1010 *  - Available templates (demo blueprints not yet installed).
     11 *  - inspect(): install/version/compatibility state for one capability_id.
     12 *
     13 * When a capability_id has a manifest (demo/capabilities/*.manifest.json,
     14 * loaded via CLAUTRON_Manifest_Registry), catalog entries carry it under
     15 * 'manifest' plus a 'has_manifest' flag — additive, so capabilities without
     16 * a manifest keep working exactly as before.
    1117 *
    1218 * External systems (e.g. AICOM) call these methods directly — no HTTP needed.
    … …  
    3339                continue;
    3440            }
     41            $manifest = class_exists( 'CLAUTRON_Manifest_Registry' ) ? CLAUTRON_Manifest_Registry::get( $data['capability_id'] ) : null;
     42
    3543            $active[] = [
    3644                'capability_id' => $data['capability_id'],
    … …  
    4250                'provides'      => $data['provides'] ?? [],
    4351                'llm_guide'     => $data['llm_guide'] ?? '',
     52                'manifest'      => $manifest,
     53                'has_manifest'  => $manifest !== null,
    4454            ];
    4555        }
    … …  
    7484                continue;
    7585            }
     86            $manifest = class_exists( 'CLAUTRON_Manifest_Registry' ) ? CLAUTRON_Manifest_Registry::get( $data['capability_id'] ) : null;
     87
    7688            $templates[] = [
    7789                'capability_id' => $data['capability_id'],
    … …  
    8395                'provides'      => $data['provides'] ?? [],
    8496                'llm_guide'     => $data['llm_guide'] ?? '',
     97                'manifest'      => $manifest,
     98                'has_manifest'  => $manifest !== null,
    8599            ];
    86100        }
    … …  
    127141        }
    128142
    129         // Find template file.
    130         $demo_dir = CLAUTRON_DIR . 'demo/';
    131         $template = null;
    132         foreach ( glob( $demo_dir . '*.json' ) ?: [] as $path ) {
    133             $data = json_decode( file_get_contents( $path ), true );
    134             if ( is_array( $data ) && ( $data['capability_id'] ?? '' ) === $capability_id ) {
    135                 $template = $data;
    136                 break;
    137             }
    138         }
     143        // Known but not active (e.g. deactivated) — reactivate the SAME
     144        // blueprint instead of creating a new one, so version history and
     145        // the blueprint's own identity survive a deactivate → install cycle.
     146        $known = class_exists( 'CLAUTRON_Capability_Store' )
     147            ? CLAUTRON_Capability_Store::find_by_capability_id( $capability_id )
     148            : null;
     149        if ( $known ) {
     150            $existing_blueprint_id = (int) $known->blueprint_id;
     151            $existing_row          = CLAUTRON_Blueprint_Store::get( $existing_blueprint_id );
     152            if ( $existing_row ) {
     153                $result = CLAUTRON_Plugin::make_compiler()->compile( $existing_blueprint_id );
     154                if ( ! empty( $result->get_errors() ) ) {
     155                    return [
     156                        'success'       => false,
     157                        'blueprint_id'  => $existing_blueprint_id,
     158                        'compiled'      => false,
     159                        'capability_id' => $capability_id,
     160                        'error'         => implode( '; ', $result->get_errors() ),
     161                    ];
     162                }
     163                self::record_installed_instance( $capability_id, $existing_blueprint_id );
     164                return [
     165                    'success'       => true,
     166                    'blueprint_id'  => $existing_blueprint_id,
     167                    'compiled'      => true,
     168                    'capability_id' => $capability_id,
     169                    'note'          => 'Reactivated existing blueprint.',
     170                    'warnings'      => $result->get_warnings(),
     171                ];
     172            }
     173            // Fall through to a fresh template install if the known
     174            // blueprint row itself is gone (shouldn't normally happen).
     175        }
     176
     177        $template = self::find_template_data( $capability_id );
    139178
    140179        if ( ! $template ) {
    … …  
    178217        }
    179218
     219        self::record_installed_instance( $capability_id, $blueprint_id );
     220
    180221        return [
    181222            'success'       => true,
    … …  
    187228    }
    188229
     230    /**
     231     * Update an already-installed capability to its manifest's current
     232     * declared version, in place — same blueprint row, new blueprint_json,
     233     * recompiled. Distinct from install(): requires the capability to
     234     * already be installed, and requires a manifest (the target version
     235     * always comes from the manifest's own 'version' field — there's no
     236     * store of multiple historical template versions to pick from).
     237     *
     238     * @return array {
     239     *   @type bool        $success
     240     *   @type string      $status         'already_current' | 'updated' (only on success)
     241     *   @type int|null    $blueprint_id
     242     *   @type string|null $from_version
     243     *   @type string|null $to_version
     244     *   @type string      $error          (only on failure)
     245     * }
     246     */
     247    public static function update( string $capability_id ): array {
     248        $installed = class_exists( 'CLAUTRON_Capability_Store' )
     249            ? CLAUTRON_Capability_Store::find_by_capability_id( $capability_id )
     250            : null;
     251
     252        if ( ! $installed ) {
     253            return [
     254                'success' => false,
     255                'error'   => "Capability '{$capability_id}' is not installed. Use clautron.catalog.install first.",
     256                'code'    => 'not_installed',
     257            ];
     258        }
     259
     260        $manifest = class_exists( 'CLAUTRON_Manifest_Registry' ) ? CLAUTRON_Manifest_Registry::get( $capability_id ) : null;
     261        if ( ! $manifest ) {
     262            return [
     263                'success' => false,
     264                'error'   => "Capability '{$capability_id}' has no manifest, so it has no declared target version to update to.",
     265                'code'    => 'no_manifest',
     266            ];
     267        }
     268
     269        $from_version = $installed->installed_version;
     270        $to_version   = $manifest['version'];
     271
     272        if ( $from_version === $to_version ) {
     273            return [
     274                'success'      => true,
     275                'status'       => 'already_current',
     276                'blueprint_id' => (int) $installed->blueprint_id,
     277                'from_version' => $from_version,
     278                'to_version'   => $to_version,
     279            ];
     280        }
     281
     282        $template = self::find_template_data( $capability_id );
     283        if ( ! $template ) {
     284            return [
     285                'success' => false,
     286                'error'   => "No template found for capability_id '{$capability_id}'.",
     287                'code'    => 'template_missing',
     288            ];
     289        }
     290
     291        $blueprint_id = (int) $installed->blueprint_id;
     292        $blueprint_row = CLAUTRON_Blueprint_Store::get( $blueprint_id );
     293        if ( ! $blueprint_row ) {
     294            return [
     295                'success' => false,
     296                'error'   => "Installed blueprint (id {$blueprint_id}) no longer exists.",
     297                'code'    => 'blueprint_missing',
     298            ];
     299        }
     300
     301        // In-place edit: keep the existing blueprint's own UUID — this
     302        // updates the same blueprint row, it doesn't create a new one.
     303        $template['id'] = $blueprint_row->uuid;
     304
     305        $validation_blueprint = new CLAUTRON_Blueprint( $template );
     306        $validation           = CLAUTRON_Blueprint_Validator::validate( $validation_blueprint );
     307        if ( ! $validation->is_valid() ) {
     308            return [
     309                'success' => false,
     310                'error'   => 'Updated template failed validation: ' . implode( '; ', $validation->get_errors() ),
     311                'code'    => 'validation_failed',
     312            ];
     313        }
     314
     315        $encoded = wp_json_encode( $template );
     316        $saved   = CLAUTRON_Blueprint_Store::update_json( $blueprint_id, $encoded, "Updated to v{$to_version} via Capability Contract" );
     317        if ( ! $saved ) {
     318            return [
     319                'success' => false,
     320                'error'   => 'Failed to save updated blueprint to database.',
     321                'code'    => 'save_failed',
     322            ];
     323        }
     324
     325        $result = CLAUTRON_Plugin::make_compiler()->compile( $blueprint_id );
     326        if ( ! empty( $result->get_errors() ) ) {
     327            return [
     328                'success'      => false,
     329                'blueprint_id' => $blueprint_id,
     330                'from_version' => $from_version,
     331                'to_version'   => $to_version,
     332                'error'        => implode( '; ', $result->get_errors() ),
     333                'code'         => 'compile_failed',
     334            ];
     335        }
     336
     337        self::record_installed_instance( $capability_id, $blueprint_id );
     338
     339        return [
     340            'success'      => true,
     341            'status'       => 'updated',
     342            'blueprint_id' => $blueprint_id,
     343            'from_version' => $from_version,
     344            'to_version'   => $to_version,
     345            'warnings'     => $result->get_warnings(),
     346        ];
     347    }
     348
     349    /**
     350     * Deactivate an installed capability — stops its compile-phase
     351     * primitives from being re-applied on the next request
     352     * (CLAUTRON_Runtime_Engine::boot() only replays workflows with
     353     * status='active'; CLAUTRON_Compiler::decompile() removes the
     354     * workflow row and pauses the blueprint). Never touches data: no
     355     * postmeta, terms, or custom table rows written while the capability
     356     * was active are read or modified. Always allowed, regardless of
     357     * reversibility.strategy — that field describes what a future
     358     * `rollback` action would (not) be able to undo, it does not gate
     359     * deactivate.
     360     *
     361     * @return array {
     362     *   @type bool     $success
     363     *   @type string   $status           'deactivated' (only on success)
     364     *   @type int|null $blueprint_id
     365     *   @type bool     $data_preserved   Always true on success.
     366     *   @type string[] $residual_effects Human-readable notes derived from the manifest's 'effects', if any.
     367     *   @type string   $error            (only on failure)
     368     * }
     369     */
     370    public static function deactivate( string $capability_id ): array {
     371        $installed = class_exists( 'CLAUTRON_Capability_Store' )
     372            ? CLAUTRON_Capability_Store::find_by_capability_id( $capability_id )
     373            : null;
     374
     375        if ( ! $installed || $installed->status !== 'active' ) {
     376            return [
     377                'success' => false,
     378                'error'   => "Capability '{$capability_id}' is not currently active.",
     379                'code'    => 'not_active',
     380            ];
     381        }
     382
     383        $blueprint_id = (int) $installed->blueprint_id;
     384
     385        CLAUTRON_Plugin::make_compiler()->decompile( $blueprint_id );
     386
     387        // Covers decompile()'s one confirmed gap: it doesn't flush rewrite
     388        // rules, so a register_cpt/register_taxonomy with a custom slug
     389        // can leave stale permalinks until the next flush. Cheap enough
     390        // for a rare, explicit operation.
     391        flush_rewrite_rules( false );
     392
     393        CLAUTRON_Capability_Store::update_status( $capability_id, 'paused' );
     394
     395        $manifest          = class_exists( 'CLAUTRON_Manifest_Registry' ) ? CLAUTRON_Manifest_Registry::get( $capability_id ) : null;
     396        $residual_effects  = self::describe_residual_effects( $manifest['effects'] ?? [] );
     397
     398        return [
     399            'success'          => true,
     400            'status'           => 'deactivated',
     401            'blueprint_id'     => $blueprint_id,
     402            'data_preserved'   => true,
     403            'residual_effects' => $residual_effects,
     404        ];
     405    }
     406
     407    /**
     408     * Translate a manifest's declared 'effects' into human-readable notes
     409     * about what deactivate does NOT undo — derived from data already
     410     * declared on the manifest, no new metadata needed.
     411     */
     412    private static function describe_residual_effects( array $effects ): array {
     413        $notes = [];
     414        if ( ! empty( $effects['writes_database'] ) ) {
     415            $notes[] = 'Data written while active (e.g. post meta) is preserved and not reverted.';
     416        }
     417        if ( ! empty( $effects['changes_schema'] ) ) {
     418            $notes[] = 'Any schema changes made while active (e.g. new fields/columns) are preserved.';
     419        }
     420        if ( ! empty( $effects['writes_files'] ) ) {
     421            $notes[] = 'Files written while active are preserved on disk.';
     422        }
     423        return $notes;
     424    }
     425
     426    /**
     427     * Locate a demo template's decoded JSON by capability_id, or null.
     428     */
     429    private static function find_template_data( string $capability_id ): ?array {
     430        $demo_dir = CLAUTRON_DIR . 'demo/';
     431        foreach ( glob( $demo_dir . '*.json' ) ?: [] as $path ) {
     432            $data = json_decode( file_get_contents( $path ), true );
     433            if ( is_array( $data ) && ( $data['capability_id'] ?? '' ) === $capability_id ) {
     434                return $data;
     435            }
     436        }
     437        return null;
     438    }
     439
     440    /**
     441     * Upsert the installed-instance row (wp_clautron_capabilities) after a
     442     * successful install/compile — separate from the generic blueprint row,
     443     * see CLAUTRON_Capability_Store.
     444     */
     445    private static function record_installed_instance( string $capability_id, int $blueprint_id ): void {
     446        if ( ! class_exists( 'CLAUTRON_Capability_Store' ) ) {
     447            return;
     448        }
     449
     450        $manifest = class_exists( 'CLAUTRON_Manifest_Registry' ) ? CLAUTRON_Manifest_Registry::get( $capability_id ) : null;
     451
     452        CLAUTRON_Capability_Store::upsert( [
     453            'capability_id'     => $capability_id,
     454            'blueprint_id'      => $blueprint_id,
     455            'installed_version' => $manifest['version'] ?? 'unknown',
     456            'status'            => 'active',
     457            'reversibility'     => $manifest['reversibility']['strategy'] ?? 'none',
     458        ] );
     459    }
     460
    189461    // ── Idempotent provisioning ───────────────────────────────────────────────
    190462
    191463    /**
    192      * Ensure a capability exists and is active. Idempotent — safe to call repeatedly.
     464     * What ensure() would do for a capability_id, without doing it. ensure()
     465     * itself follows this, so a dry run can never disagree with the real call.
    193466     *
    194467     * Resolution order:
    195      *  1. Already active with this capability_id → return it unchanged.
    196      *  2. Available as a demo template → install it.
    197      *  3. blueprint_json provided → create + compile (capability_id is forced into the JSON).
     468     *  1. already_active   — active with this capability_id: nothing to do.
     469     *  2. reactivate       — known but deactivated, blueprint still there:
     470     *                        install() recompiles the SAME blueprint. Without
     471     *                        this a custom (non-template) capability would get
     472     *                        a second blueprint every time it was ensured
     473     *                        after a deactivate.
     474     *  3. install_template — a demo template: install it.
     475     *  4. create           — create + compile the caller's blueprint_json.
     476     *
     477     * @return array{action: string, blueprint_id: ?int}
     478     */
     479    public static function ensure_plan( string $capability_id ): array {
     480        $cap = self::find_active( $capability_id );
     481        if ( $cap ) {
     482            return [ 'action' => 'already_active', 'blueprint_id' => (int) $cap['blueprint_id'] ];
     483        }
     484
     485        $known = CLAUTRON_Capability_Store::find_by_capability_id( $capability_id );
     486        if ( $known && CLAUTRON_Blueprint_Store::get( (int) $known->blueprint_id ) ) {
     487            return [ 'action' => 'reactivate', 'blueprint_id' => (int) $known->blueprint_id ];
     488        }
     489
     490        if ( in_array( $capability_id, array_column( self::get_templates(), 'capability_id' ), true ) ) {
     491            return [ 'action' => 'install_template', 'blueprint_id' => null ];
     492        }
     493
     494        return [ 'action' => 'create', 'blueprint_id' => null ];
     495    }
     496
     497    /**
     498     * Ensure a capability exists and is active. Idempotent — safe to call
     499     * repeatedly. Resolution order: see ensure_plan().
    198500     *
    199501     * @param string $capability_id  Unique slug identifying this capability.
    … …  
    201503     * @return array {
    202504     *   @type bool   $success
    203      *   @type string $status        'already_active' | 'installed_from_template' | 'created_and_compiled'
     505     *   @type string $status        'already_active' | 'reactivated' | 'installed_from_template' | 'created_and_compiled'
    204506     *   @type string $capability_id
    205507     *   @type int    $blueprint_id
    … …  
    208510     */
    209511    public static function ensure( string $capability_id, string $blueprint_json = '' ): array {
    210         // ── 1. Already active ─────────────────────────────────────────────────
    211         $cap = self::find_active( $capability_id );
    212         if ( $cap ) {
     512        $plan = self::ensure_plan( $capability_id );
     513
     514        if ( 'already_active' === $plan['action'] ) {
    213515            return [
    214516                'success'       => true,
    215517                'status'        => 'already_active',
    216518                'capability_id' => $capability_id,
    217                 'blueprint_id'  => $cap['blueprint_id'],
    218             ];
    219         }
    220 
    221         // ── 2. Available as template ──────────────────────────────────────────
    222         $templates = self::get_templates();
    223         foreach ( $templates as $tpl ) {
    224             if ( $tpl['capability_id'] === $capability_id ) {
    225                 $result           = self::install( $capability_id );
    226                 $result['status'] = $result['success'] ? 'installed_from_template' : ( $result['status'] ?? 'error' );
    227                 return $result;
    228             }
    229         }
    230 
    231         // ── 3. Create from provided blueprint_json ────────────────────────────
     519                'blueprint_id'  => $plan['blueprint_id'],
     520            ];
     521        }
     522
     523        if ( 'reactivate' === $plan['action'] || 'install_template' === $plan['action'] ) {
     524            $result           = self::install( $capability_id );
     525            $result['status'] = $result['success']
     526                ? ( 'reactivate' === $plan['action'] ? 'reactivated' : 'installed_from_template' )
     527                : ( $result['status'] ?? 'error' );
     528            return $result;
     529        }
     530
     531        // ── create: from the provided blueprint_json ──────────────────────────
    232532        if ( ! $blueprint_json ) {
    233533            return [
    … …  
    284584        }
    285585
     586        self::record_installed_instance( $capability_id, $blueprint_id );
     587
    286588        return [
    287589            'success'       => true,
    … …  
    366668    }
    367669
     670    // ── Inspect ───────────────────────────────────────────────────────────────
     671
     672    /**
     673     * Contract-level actions that always work for any capability_id,
     674     * manifest or not — they're generic lookups, never gated by what a
     675     * specific manifest opts into declaring.
     676     */
     677    private const ALWAYS_AVAILABLE_ACTIONS = [ 'discover', 'inspect' ];
     678
     679    /**
     680     * Per-capability lifecycle actions the Capability Contract currently
     681     * has a real, working executor for. A manifest may *declare* more
     682     * (e.g. "update", "verify", "remove") than the contract can actually
     683     * run yet — available_actions only ever advertises the intersection,
     684     * so a caller never gets pointed at an action that would just fail.
     685     */
     686    private const IMPLEMENTED_LIFECYCLE_ACTIONS = [ 'install', 'update', 'deactivate' ];
     687
     688    /**
     689     * Resolve current install/version/compatibility state for a capability_id.
     690     *
     691     * @return array {
     692     *   @type bool        $installed
     693     *   @type string|null $installed_version
     694     *   @type string|null $available_version   From the manifest, if one exists.
     695     *   @type bool        $compatible           True if no unmet requirements (or no manifest to check).
     696     *   @type string      $status               not_installed|active|paused|removed
     697     *   @type string[]    $available_actions    ALWAYS_AVAILABLE_ACTIONS + (manifest actions ∩ IMPLEMENTED_LIFECYCLE_ACTIONS).
     698     *   @type string[]    $unmet_requirements
     699     * }
     700     */
     701    public static function inspect( string $capability_id ): array {
     702        $manifest = class_exists( 'CLAUTRON_Manifest_Registry' ) ? CLAUTRON_Manifest_Registry::get( $capability_id ) : null;
     703
     704        $installed_row = class_exists( 'CLAUTRON_Capability_Store' )
     705            ? CLAUTRON_Capability_Store::find_by_capability_id( $capability_id )
     706            : null;
     707
     708        $unmet = ( $manifest && class_exists( 'CLAUTRON_Manifest_Registry' ) )
     709            ? CLAUTRON_Manifest_Registry::check_requirements( $manifest )
     710            : [];
     711
     712        $declared_actions  = $manifest['actions'] ?? [];
     713        $available_actions = array_values( array_unique( array_merge(
     714            self::ALWAYS_AVAILABLE_ACTIONS,
     715            array_intersect( $declared_actions, self::IMPLEMENTED_LIFECYCLE_ACTIONS )
     716        ) ) );
     717
     718        return [
     719            'installed'          => $installed_row !== null,
     720            'installed_version'  => $installed_row->installed_version ?? null,
     721            'available_version'  => $manifest['version'] ?? null,
     722            'compatible'         => empty( $unmet ),
     723            'status'             => $installed_row->status ?? 'not_installed',
     724            'available_actions'  => $available_actions,
     725            'unmet_requirements' => $unmet,
     726        ];
     727    }
     728
    368729    // ── Helpers ───────────────────────────────────────────────────────────────
    369730
  • clautron/trunk/includes/class-db.php

    r3582869 r3784837  
    1414 *   3 — add last_error column to jobs table
    1515 *   4 — add clautron_events time-series table
     16 *   5 — add clautron_capabilities table (installed-instance state, separate
     17 *       from the source/template manifest — see docs/schemas/capability_manifest.json)
    1618 */
    1719class CLAUTRON_DB {
    … …  
    193195        ) $charset;" );
    194196
     197        // ── capabilities ──────────────────────────────────────────────────────
     198        // Installed-instance state for a capability, separate from the
     199        // source/template manifest (demo/capabilities/*.manifest.json) and
     200        // from the generic blueprints table, which serves every blueprint
     201        // whether or not it's a governed capability. One row per capability_id
     202        // currently installed on this site.
     203        dbDelta( "CREATE TABLE {$pfx}capabilities (
     204            id                 BIGINT UNSIGNED  NOT NULL AUTO_INCREMENT,
     205            capability_id      VARCHAR(191)     NOT NULL,
     206            blueprint_id       BIGINT UNSIGNED  NOT NULL,
     207            installed_version  VARCHAR(20)      NOT NULL DEFAULT 'unknown',
     208            status             VARCHAR(20)      NOT NULL DEFAULT 'active',
     209            reversibility      VARCHAR(10)      NOT NULL DEFAULT 'none',
     210            installed_at       DATETIME         NOT NULL,
     211            updated_at         DATETIME         NOT NULL,
     212            PRIMARY KEY  (id),
     213            UNIQUE KEY   capability_id (capability_id),
     214            KEY          blueprint_id (blueprint_id)
     215        ) $charset;" );
     216
    195217        // ── In-place migrations for existing installs ─────────────────────────
    196218        // dbDelta reliably creates tables but does NOT add missing columns.
    … …  
    203225        }
    204226
     227        // v5: backfill capabilities table for blueprints that were installed
     228        // (compiled + active, with a capability_id) before this table existed.
     229        // Preserves continuity — nothing about an already-installed capability
     230        // changes on upgrade, it just becomes visible to the new inspect()/
     231        // versioning machinery.
     232        self::backfill_capabilities();
     233
    205234        update_option( 'clautron_db_version', CLAUTRON_DB_VERSION );
     235    }
     236
     237    /**
     238     * One-time backfill: for every active blueprint that already declares a
     239     * capability_id but has no row yet in the capabilities table, insert one.
     240     * Safe to call repeatedly — skips capability_ids that already have a row.
     241     */
     242    private static function backfill_capabilities(): void {
     243        global $wpdb;
     244
     245        $existing_ids = $wpdb->get_col( $wpdb->prepare( 'SELECT capability_id FROM %i', self::capabilities() ) );
     246
     247        $rows = $wpdb->get_results( $wpdb->prepare(
     248            'SELECT id, blueprint_json FROM %i WHERE status = %s',
     249            self::blueprints(),
     250            'active'
     251        ) );
     252
     253        $now = current_time( 'mysql', true );
     254
     255        foreach ( $rows as $row ) {
     256            $data = json_decode( $row->blueprint_json, true );
     257            if ( ! is_array( $data ) || empty( $data['capability_id'] ) ) {
     258                continue;
     259            }
     260            if ( in_array( $data['capability_id'], $existing_ids, true ) ) {
     261                continue;
     262            }
     263
     264            $manifest          = class_exists( 'CLAUTRON_Manifest_Registry' ) ? CLAUTRON_Manifest_Registry::get( $data['capability_id'] ) : null;
     265            $installed_version = $manifest['version'] ?? 'unknown';
     266            $reversibility     = $manifest['reversibility']['strategy'] ?? 'none';
     267
     268            $wpdb->insert(
     269                self::capabilities(),
     270                [
     271                    'capability_id'     => $data['capability_id'],
     272                    'blueprint_id'      => (int) $row->id,
     273                    'installed_version' => $installed_version,
     274                    'status'            => 'active',
     275                    'reversibility'     => $reversibility,
     276                    'installed_at'      => $now,
     277                    'updated_at'        => $now,
     278                ],
     279                [ '%s', '%d', '%s', '%s', '%s', '%s', '%s' ]
     280            );
     281        }
    206282    }
    207283
    … …  
    212288        global $wpdb;
    213289        $pfx    = $wpdb->prefix . 'clautron_';
    214         $tables = [ 'events', 'jobs', 'ai_requests', 'conversations', 'execution_logs', 'workflows', 'versions', 'blueprints' ];
     290        $tables = [ 'capabilities', 'events', 'jobs', 'ai_requests', 'conversations', 'execution_logs', 'workflows', 'versions', 'blueprints' ];
    215291        foreach ( $tables as $t ) {
    216292            $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $pfx . $t ) );
    … …  
    229305    public static function jobs(): string          { global $wpdb; return $wpdb->prefix . 'clautron_jobs'; }
    230306    public static function events(): string        { global $wpdb; return $wpdb->prefix . 'clautron_events'; }
     307    public static function capabilities(): string  { global $wpdb; return $wpdb->prefix . 'clautron_capabilities'; }
    231308}
  • clautron/trunk/includes/class-plugin.php

    r3691441 r3784837  
    4242        CLAUTRON_Primitive_Redirect::register_all();
    4343        CLAUTRON_Primitive_Cookie::register_all();
     44        CLAUTRON_Primitive_Consent::register_all();
    4445        CLAUTRON_Primitive_Language::register_all();
    4546        CLAUTRON_Primitive_Request::register_all();
    … …  
    5960        // ── 3. Boot job runner ───────────────────────────────────────────────
    6061        CLAUTRON_Job_Runner::boot();
     62
     63        // ── 3b. Expose operations as WordPress Abilities ─────────────────────
     64        CLAUTRON_Abilities::boot();
    6165
    6266        // ── 4b. Boot runtime engine ──────────────────────────────────────────
  • clautron/trunk/includes/class-smoke-tester.php

    r3582869 r3784837  
    55 * Smoke Tester — verifies that a compiled blueprint is correctly wired into WordPress.
    66 *
    7  * Checks are lightweight WordPress-introspection only (no HTTP requests).
    8  * Each check inspects the WordPress state that the compile-phase primitives
    9  * should have set up: post types, taxonomies, meta boxes, columns, hooks, etc.
     7 * Generic executor: each compile-phase structure node is checked by its own
     8 * primitive's declared `verify` callable (CLAUTRON_Primitive_Registry). A
     9 * primitive type with no `verify` declared lands in `unavailable`, not
     10 * silently dropped — see the primitive's own file for its check, and
     11 * CLAUTRON_Primitive_Registry::register()'s docblock for the `verify`
     12 * contract. Part of the AICOM-Clautron Capability Contract v1.
    1013 */
    1114class CLAUTRON_Smoke_Tester {
    … …  
    2023     *   @type string[] $failed
    2124     *   @type string[] $warnings
     25     *   @type string[] $unavailable  Structures whose primitive type has no declared verify check.
    2226     *   @type string   $summary
    2327     * }
    … …  
    2630        $row = CLAUTRON_Blueprint_Store::get( $blueprint_id );
    2731        if ( ! $row ) {
    28             return self::result( [], [], [ "Blueprint {$blueprint_id} not found." ], 0, 0 );
     32            return self::result( [], [], [ "Blueprint {$blueprint_id} not found." ], [] );
    2933        }
    3034
    3135        $data = json_decode( $row->blueprint_json, true );
    3236        if ( ! is_array( $data ) ) {
    33             return self::result( [], [], [ 'blueprint_json is not valid JSON.' ], 0, 0 );
     37            return self::result( [], [], [ 'blueprint_json is not valid JSON.' ], [] );
    3438        }
    3539
    3640        if ( $row->status !== 'active' ) {
    37             return self::result( [], [], [ "Blueprint is '{$row->status}', not active — compile it first." ], 0, 0 );
     41            return self::result( [], [], [ "Blueprint is '{$row->status}', not active — compile it first." ], [] );
    3842        }
    3943
    40         $passed   = [];
    41         $failed   = [];
    42         $warnings = [];
     44        $passed      = [];
     45        $failed      = [];
     46        $warnings    = [];
     47        $unavailable = [];
    4348
    4449        // ── Check trigger ──────────────────────────────────────────────────────
     50        // Not a primitive — the blueprint's own trigger field, checked directly.
    4551        $trigger = $data['trigger'] ?? null;
    4652        if ( $trigger ) {
    … …  
    5965            $id     = $node['id'] ?? $type;
    6066
    61             switch ( $type ) {
     67            $def = class_exists( 'CLAUTRON_Primitive_Registry' ) ? CLAUTRON_Primitive_Registry::get( $type ) : null;
    6268
    63                 case 'register_cpt': {
    64                     $pt = $config['post_type'] ?? '';
    65                     if ( $pt && get_post_type_object( $pt ) ) {
    66                         $passed[] = "register_cpt:{$pt}";
    67                     } elseif ( $pt ) {
    68                         $failed[] = "register_cpt:{$pt} — post type not registered.";
    69                     }
    70                     break;
    71                 }
     69            if ( ! $def ) {
     70                $unavailable[] = "{$type}:{$id} — primitive type not registered.";
     71                continue;
     72            }
    7273
    73                 case 'register_taxonomy': {
    74                     $tax = $config['taxonomy'] ?? '';
    75                     if ( $tax && taxonomy_exists( $tax ) ) {
    76                         $passed[] = "register_taxonomy:{$tax}";
    77                     } elseif ( $tax ) {
    78                         $failed[] = "register_taxonomy:{$tax} — taxonomy not registered.";
    79                     }
    80                     break;
    81                 }
     74            if ( empty( $def['verify'] ) || ! is_callable( $def['verify'] ) ) {
     75                $unavailable[] = "{$type}:{$id} — no verification declared for this primitive type.";
     76                continue;
     77            }
    8278
    83                 case 'register_meta': {
    84                     $key = $config['meta_key'] ?? '';
    85                     $pts = (array) ( $config['post_types'] ?? [ 'post' ] );
    86                     if ( $key ) {
    87                         $registered = registered_meta_key_exists( 'post', $key, $pts[0] ?? '' );
    88                         if ( $registered ) {
    89                             $passed[] = "register_meta:{$key}";
    90                         } else {
    91                             $warnings[] = "register_meta:{$key} — not found in registered meta (may still work via update_post_meta).";
    92                         }
    93                     }
    94                     break;
    95                 }
     79            $items = call_user_func( $def['verify'], $config );
     80            foreach ( (array) $items as $item ) {
     81                $label  = $item['label']  ?? "{$type}:{$id}";
     82                $reason = $item['reason'] ?? null;
     83                $line   = $reason ? "{$label} — {$reason}" : $label;
    9684
    97                 case 'add_metabox': {
    98                     $mbid = $config['id'] ?? '';
    99                     $pts  = (array) ( $config['post_types'] ?? [ 'post' ] );
    100                     $pt   = $pts[0] ?? 'post';
    101                     // Meta boxes are only registered in admin context.
    102                     if ( ! is_admin() ) {
    103                         $warnings[] = "add_metabox:{$mbid} — skipped (requires admin context; will work correctly on post edit screens).";
     85                switch ( $item['status'] ?? 'warning' ) {
     86                    case 'passed':
     87                        $passed[] = $line;
    10488                        break;
    105                     }
    106                     $hook_name = 'add_meta_boxes_' . $pt;
    107                     do_action( $hook_name, null );
    108                     do_action( 'add_meta_boxes', $pt, null );
    109                     global $wp_meta_boxes;
    110                     $found = false;
    111                     foreach ( $wp_meta_boxes[ $pt ] ?? [] as $context ) {
    112                         foreach ( $context as $priority ) {
    113                             if ( isset( $priority[ $mbid ] ) ) {
    114                                 $found = true;
    115                                 break 2;
    116                             }
    117                         }
    118                     }
    119                     if ( $found ) {
    120                         $passed[] = "add_metabox:{$mbid}";
    121                     } else {
    122                         $failed[] = "add_metabox:{$mbid} — not found in wp_meta_boxes for post type '{$pt}'.";
    123                     }
    124                     break;
    125                 }
    126 
    127                 case 'add_admin_column': {
    128                     $pts = (array) ( $config['post_types'] ?? [] );
    129                     foreach ( $pts as $pt ) {
    130                         $filter = "manage_{$pt}_posts_columns";
    131                         if ( has_filter( $filter ) ) {
    132                             $passed[] = "add_admin_column:{$pt}";
    133                         } else {
    134                             $failed[] = "add_admin_column:{$pt} — filter '{$filter}' not hooked.";
    135                         }
    136                     }
    137                     break;
    138                 }
    139 
    140                 case 'inject_wp_head': {
    141                     $priority = (int) ( $config['priority'] ?? 5 );
    142                     if ( has_action( 'wp_head' ) ) {
    143                         $passed[] = "inject_wp_head (priority:{$priority})";
    144                     } else {
    145                         $failed[] = 'inject_wp_head — no actions registered on wp_head.';
    146                     }
    147                     break;
    148                 }
    149 
    150                 case 'inject_wp_footer': {
    151                     if ( has_action( 'wp_footer' ) ) {
    152                         $passed[] = 'inject_wp_footer';
    153                     } else {
    154                         $failed[] = 'inject_wp_footer — no actions registered on wp_footer.';
    155                     }
    156                     break;
    157                 }
    158 
    159                 case 'register_rest_route': {
    160                     $route = $config['route'] ?? '';
    161                     if ( $route ) {
    162                         $routes    = rest_get_server()->get_routes();
    163                         $full_path = '/clautron/v1/' . ltrim( $route, '/' );
    164                         if ( isset( $routes[ $full_path ] ) ) {
    165                             $passed[] = "register_rest_route:{$route}";
    166                         } else {
    167                             $warnings[] = "register_rest_route:{$route} — not found in registered routes (REST server may not be initialized yet).";
    168                         }
    169                     }
    170                     break;
    171                 }
    172 
    173                 case 'add_row_action': {
    174                     $pts = (array) ( $config['post_types'] ?? [] );
    175                     foreach ( $pts as $pt ) {
    176                         $filter = "post_row_actions";
    177                         if ( has_filter( $filter ) || has_filter( "{$pt}_row_actions" ) ) {
    178                             $passed[] = "add_row_action:{$pt}";
    179                         } else {
    180                             $warnings[] = "add_row_action:{$pt} — row action filter not detected (may still work).";
    181                         }
    182                     }
    183                     break;
    184                 }
    185 
    186                 case 'gate_publish': {
    187                     if ( has_action( 'admin_notices' ) ) {
    188                         $passed[] = 'gate_publish';
    189                     } else {
    190                         $warnings[] = 'gate_publish — admin_notices hook not detected in current context.';
    191                     }
    192                     break;
     89                    case 'failed':
     90                        $failed[] = $line;
     91                        break;
     92                    default:
     93                        $warnings[] = $line;
     94                        break;
    19395                }
    19496            }
    19597        }
    19698
    197         $total = count( $passed ) + count( $failed );
    198 
    199         return self::result( $passed, $failed, $warnings, count( $passed ), $total );
     99        return self::result( $passed, $failed, $warnings, $unavailable );
    200100    }
    201101
    … …  
    206106        array $failed,
    207107        array $warnings,
    208         int   $pass_count,
    209         int   $total
     108        array $unavailable
    210109    ): array {
     110        $pass_count = count( $passed );
     111        $total      = $pass_count + count( $failed );
     112
    211113        if ( $total === 0 ) {
    212             $summary = 'No checkable structures found.';
     114            $summary = empty( $unavailable ) ? 'No checkable structures found.' : 'No checks could run.';
    213115        } elseif ( empty( $failed ) ) {
    214116            $summary = "All checks passed ({$pass_count}/{$total}).";
    … …  
    218120        }
    219121
     122        if ( ! empty( $unavailable ) ) {
     123            $summary .= ' ' . count( $unavailable ) . ' structure(s) have no verification declared.';
     124        }
     125
    220126        return [
    221             'passed'   => $passed,
    222             'failed'   => $failed,
    223             'warnings' => $warnings,
    224             'summary'  => $summary,
     127            'passed'      => $passed,
     128            'failed'      => $failed,
     129            'warnings'    => $warnings,
     130            'unavailable' => $unavailable,
     131            'summary'     => $summary,
    225132        ];
    226133    }
  • clautron/trunk/includes/primitives/class-primitive-ai.php

    r3691441 r3784837  
    2727            'category'    => 'ai',
    2828            'phase'       => 'runtime',
     29            'effects'     => [ 'external_requests' ],
    2930            'label'       => 'AI Generate',
    3031            'description' => 'Generate text with the WordPress AI Client (site-configured provider) and store the result in a context variable. Use for excerpts, meta descriptions, alt text, translations, summaries. Prompt supports {{variable}} placeholders. Output is plain text only.',
  • clautron/trunk/includes/primitives/class-primitive-comments.php

    r3691441 r3784837  
    2929            'category'    => 'comments',
    3030            'phase'       => 'runtime',
     31            'effects'     => [],
    3132            'label'       => 'Query Comments',
    3233            'description' => 'Query WordPress comments with filters. Returns array of comments (id, post_id, author, author_email, content, status, date, parent_id) stored in a context variable.',
    … …  
    8586            'category'    => 'comments',
    8687            'phase'       => 'runtime',
     88            // approve: core emails the post author (wp_set_comment_status → wp_new_comment_notify_postauthor).
     89            'effects'     => static fn( array $c ): array => [ 'trash' => [ 'writes_database', 'deletes_data' ], 'approve' => [ 'writes_database', 'external_requests' ] ][ $c['status'] ?? '' ] ?? [ 'writes_database' ],
     90            'effects_max' => [ 'writes_database', 'deletes_data', 'external_requests' ],
    8791            'label'       => 'Moderate Comment',
    8892            'description' => 'Change a comment\'s moderation status. Allowed statuses: approve, hold, spam, trash. Hard deletion is intentionally not supported.',
    … …  
    123127            'category'    => 'comments',
    124128            'phase'       => 'runtime',
     129            'effects'     => [ 'writes_database' ],
    125130            'label'       => 'Create Comment',
    126131            'description' => 'Insert a comment (or a threaded reply via parent_id) on a post. Content supports {{variable}} placeholders.',
  • clautron/trunk/includes/primitives/class-primitive-cookie.php

    r3691441 r3784837  
    3737            'category'    => 'data',
    3838            'phase'       => 'runtime',
     39            'effects'     => [],
     40            'reversible'  => false,
    3941            'safe'        => false,
    4042            'label'       => 'Write Cookie',
    … …  
    8385            'category'    => 'data',
    8486            'phase'       => 'runtime',
     87            'effects'     => [],
    8588            'label'       => 'Read Cookie',
    8689            'description' => 'Read a clautron_-prefixed cookie into a workflow variable. Resolves to null if the cookie is not set.',
  • clautron/trunk/includes/primitives/class-primitive-data.php

    r3582869 r3784837  
    3636            'category'    => 'data',
    3737            'phase'       => 'runtime',
     38            'effects'     => [],
    3839            'label'       => 'Read Post Field',
    3940            'description' => 'Read a field from the current post (ID, title, content, status, author, etc.) and store it in a variable.',
    … …  
    8586            'category'    => 'data',
    8687            'phase'       => 'runtime',
     88            'effects'     => [ 'writes_database' ],
    8789            'label'       => 'Write Meta',
    8890            'description' => 'Write a meta value to a post, user, or term.',
    … …  
    122124            'category'    => 'data',
    123125            'phase'       => 'runtime',
     126            'effects'     => [],
    124127            'label'       => 'Read Meta',
    125128            'description' => 'Read a meta value from a post, user, or term and store it in a variable.',
    … …  
    161164            'category'    => 'data',
    162165            'phase'       => 'runtime',
     166            'effects'     => [ 'writes_database' ],
    163167            'safe'        => false,
    164168            'label'       => 'Write Option',
    … …  
    187191            'category'    => 'data',
    188192            'phase'       => 'runtime',
     193            'effects'     => [],
    189194            'label'       => 'Read Option',
    190195            'description' => 'Read a WordPress option and store it in a variable.',
    … …  
    324329            'category'    => 'wordpress',
    325330            'phase'       => 'compile',
     331            'effects'     => [ 'writes_database' ],
    326332            'label'       => 'Gate Publish',
    327333            'description' => 'Block or warn when publishing a post that fails completeness checks.',
    … …  
    407413                    echo '</p></div>';
    408414                } );
     415            },
     416            'verify' => static function ( array $config ): array {
     417                if ( has_action( 'admin_notices' ) ) {
     418                    return [ [ 'status' => 'passed', 'label' => 'gate_publish' ] ];
     419                }
     420                return [ [ 'status' => 'warning', 'label' => 'gate_publish', 'reason' => 'admin_notices hook not detected in current context.' ] ];
    409421            },
    410422        ] );
    … …  
    478490            'category'    => 'data',
    479491            'phase'       => 'runtime',
     492            'effects'     => [ 'writes_database' ],
    480493            'label'       => 'Check Fields Completeness',
    481494            'description' => 'Evaluate a list of post fields and calculate a completeness score (0-100). Saves the score to a meta key and an output variable.',
    … …  
    563576            'category'    => 'data',
    564577            'phase'       => 'runtime',
     578            'effects'     => [],
    565579            'label'       => 'Run Report',
    566580            'description' => 'Execute a saved report blueprint and store its result rows in a context variable. Use with foreach_query_result to process each row.',
  • clautron/trunk/includes/primitives/class-primitive-elementor.php

    r3592659 r3784837  
    3939            'category'    => 'elementor',
    4040            'phase'       => 'compile',
     41            'effects'     => [ 'executes_code' ],
    4142            'label'       => 'Elementor Widget',
    4243            'description' => 'Create a custom Elementor widget with content controls, style tab sections, and a server-rendered HTML template. Appears under the "Clautron" category in the Elementor panel.',
    … …  
    9798            'category'    => 'wordpress',
    9899            'phase'       => 'compile',
     100            'effects'     => [ 'executes_code' ],
    99101            'label'       => 'WordPress Widget',
    100102            'description' => 'Register a native WordPress sidebar widget (WP_Widget) usable in Appearance > Widgets and any theme sidebar. Supports text, textarea, number, url, color, checkbox, and select controls. Use {{key}} (escaped), {{{key}}} (HTML), {{key.url}} (URL), and {{#if key}}...{{/if}} in the template.',
  • clautron/trunk/includes/primitives/class-primitive-email.php

    r3691441 r3784837  
    2020            'category'    => 'communication',
    2121            'phase'       => 'runtime',
     22            'effects'     => [ 'external_requests' ],
    2223            'label'       => 'Send Email',
    2324            'description' => 'Send an email via wp_mail(). All fields support {{variable}} template syntax.',
    … …  
    6970            'category'    => 'communication',
    7071            'phase'       => 'runtime',
     72            'effects'     => [ 'external_requests' ],
    7173            'label'       => 'Notify User',
    7274            'description' => 'Send a notification email to a WordPress user by their user ID. Subject and body support {{variable}} template syntax.',
    … …  
    129131            'category'    => 'communication',
    130132            'phase'       => 'compile',
     133            'effects'     => [ 'external_requests' ],
    131134            'safe'        => false,
    132135            'label'       => 'Configure SMTP',
  • clautron/trunk/includes/primitives/class-primitive-events.php

    r3691441 r3784837  
    3232            'category'    => 'events',
    3333            'phase'       => 'runtime',
     34            'effects'     => [ 'writes_database' ],
    3435            'label'       => 'Track Event',
    3536            'description' => 'Write one event to the clautron_events time-series store. Use this inside any blueprint trigger to record that something happened (a pageview, a login failure, an order, a cron run, etc.).',
    … …  
    7879            'category'    => 'events',
    7980            'phase'       => 'runtime',
     81            'effects'     => [],
    8082            'label'       => 'Query Events',
    8183            'description' => 'Read raw event rows from the clautron_events store. Results are stored in output_variable for use in subsequent primitives (foreach_query_result, send_email, etc.).',
    … …  
    120122            'category'    => 'events',
    121123            'phase'       => 'runtime',
     124            'effects'     => [],
    122125            'label'       => 'Aggregate Events',
    123126            'description' => 'Group and aggregate event data: total counts, sums, averages, grouped by time period or object. Returns an array of {period, value} rows in output_variable.',
    … …  
    160163            'category'    => 'events',
    161164            'phase'       => 'runtime',
     165            'effects'     => [ 'writes_database', 'deletes_data' ],
    162166            'label'       => 'Purge Events',
    163167            'description' => 'Delete old events for retention management. Use inside a recurring job (e.g. weekly). Returns the count of deleted rows in output_variable.',
    … …  
    201205            'category'    => 'events',
    202206            'phase'       => 'compile',
     207            'effects'     => [ 'writes_database' ],
    203208            'safe'        => false,
    204209            'label'       => 'Log All Outgoing Email',
    … …  
    256261            'category'    => 'events',
    257262            'phase'       => 'compile',
     263            'effects'     => [ 'writes_database' ],
    258264            'safe'        => false,
    259265            'label'       => 'Log Site Activity',
  • clautron/trunk/includes/primitives/class-primitive-filter.php

    r3691441 r3784837  
    3333            'category'    => 'frontend',
    3434            'phase'       => 'compile',
     35            'effects'     => [],
    3536            'label'       => 'Filter Query by Request Parameters',
    3637            'description' => 'Restrict frontend main queries (archives, search, home) using one or more filter rules read from GET parameters: taxonomy facets, exact meta match, or numeric meta range. Built for shop/archive filter sidebars (category, in-stock, price range). Never touches wp-admin or REST requests.',
  • clautron/trunk/includes/primitives/class-primitive-frontend.php

    r3691441 r3784837  
    2626            'category'    => 'frontend',
    2727            'phase'       => 'compile',
     28            'effects'     => [ 'executes_code' ],
    2829            'label'       => 'Inject into <head>',
    2930            'description' => 'Output HTML into wp_head on singular post/page views. Supports {{post.field}}, {{site.field}}, and {{_meta_key}} placeholders. Optionally outputs a Schema.org JSON-LD block via json_ld config.',
    … …  
    118119                }, $priority );
    119120            },
     121            'verify' => static function ( array $config ): array {
     122                $priority = (int) ( $config['priority'] ?? 5 );
     123                if ( has_action( 'wp_head' ) ) {
     124                    return [ [ 'status' => 'passed', 'label' => "inject_wp_head (priority:{$priority})" ] ];
     125                }
     126                return [ [ 'status' => 'failed', 'label' => 'inject_wp_head', 'reason' => 'no actions registered on wp_head.' ] ];
     127            },
    120128        ] );
    121129    }
    … …  
    127135            'category'    => 'frontend',
    128136            'phase'       => 'compile',
     137            'effects'     => [ 'executes_code' ],
    129138            'label'       => 'Inject before </body>',
    130139            'description' => 'Output HTML into wp_footer. Same {{variable}} resolution as inject_wp_head (post.*, site.*, meta keys). Leave post_types empty to render on every frontend page (banners, popups, share widgets) — set it to restrict to singular views of those post types, where {{post.*}}/meta-key tokens resolve against the viewed post. Set suppress_if_cookie to hide the whole block once a clautron_-prefixed cookie (e.g. one set by write_cookie) has any non-empty value — the standard pattern for "don\'t show this again after accept/dismiss".',
    … …  
    180189                }, $priority );
    181190            },
     191            'verify' => static function ( array $config ): array {
     192                if ( has_action( 'wp_footer' ) ) {
     193                    return [ [ 'status' => 'passed', 'label' => 'inject_wp_footer' ] ];
     194                }
     195                return [ [ 'status' => 'failed', 'label' => 'inject_wp_footer', 'reason' => 'no actions registered on wp_footer.' ] ];
     196            },
    182197        ] );
    183198    }
    … …  
    189204            'category'    => 'frontend',
    190205            'phase'       => 'compile',
     206            'effects'     => [ 'executes_code' ],
    191207            'label'       => 'Register Shortcode',
    192208            'description' => 'Register a WordPress [shortcode] rendered from an HTML template. Attributes become template tokens: {{attr}} escaped text, {{{attr}}} sanitized HTML, {{attr.url}} URL, {{#if attr}}...{{/if}} conditionals. Enclosed content is available as {{{content}}}.',
    … …  
    272288            'category'    => 'frontend',
    273289            'phase'       => 'compile',
     290            'effects'     => [],
    274291            'label'       => 'Enqueue Registered Asset',
    275292            'description' => 'Load CSS/JS assets that are ALREADY registered on the site (by core, the theme, or plugins) onto frontend pages, by handle. Never loads external URLs or new code — only activates assets the site already ships (e.g. dashicons, wp-block-library, swiper, theme handles). Use widget/shortcode templates with the classes those assets provide.',
  • clautron/trunk/includes/primitives/class-primitive-http.php

    r3582869 r3784837  
    2424            'category'    => 'http',
    2525            'phase'       => 'runtime',
     26            'effects'     => [ 'external_requests' ],
     27            'safe'        => false,
    2628            'label'       => 'HTTP Request',
    2729            'description' => 'Send an HTTP request (GET, POST, PUT, DELETE, PATCH) and store the response body and status code in context variables.',
    … …  
    112114            'category'    => 'http',
    113115            'phase'       => 'runtime',
     116            'effects'     => [ 'external_requests', 'writes_database' ], // SimplePie caches the feed in transients
     117            'safe'        => false,
    114118            'label'       => 'Fetch RSS/Atom Feed',
    115119            'description' => 'Fetch an RSS or Atom feed and store items as an array of {title, link, description, pubdate} objects.',
    116             'safe'        => true,
    117120            'schema'      => [
    118121                'url'             => [ 'type' => 'string',  'required' => true, 'description' => 'Feed URL. Supports {{variable}}.' ],
    … …  
    170173            'category'    => 'http',
    171174            'phase'       => 'runtime',
     175            'effects'     => [ 'external_requests' ],
    172176            'label'       => 'Send Webhook',
    173177            'description' => 'POST a JSON payload to a webhook URL. Supports {{variable}} in URL and payload values.',
  • clautron/trunk/includes/primitives/class-primitive-language.php

    r3691441 r3784837  
    2929            'category'    => 'frontend',
    3030            'phase'       => 'compile',
     31            'effects'     => [],
    3132            'label'       => 'Filter Query by Language',
    3233            'description' => 'Restrict frontend main queries (archives, search, home, singular) to posts tagged with one term of a language taxonomy. Language is resolved from a URL query var, falling back to a cookie, falling back to a configured default. Never touches wp-admin or REST requests.',
  • clautron/trunk/includes/primitives/class-primitive-logic.php

    r3582869 r3784837  
    3030            'category'    => 'logic',
    3131            'phase'       => 'runtime',
     32            'effects'     => [],
    3233            'label'       => 'If / Else',
    3334            'description' => 'Evaluate a condition. Execute pass_branch nodes if true, fail_branch nodes if false.',
    … …  
    7475            'category'    => 'logic',
    7576            'phase'       => 'runtime',
     77            'effects'     => [],
    7678            'label'       => 'Stop Execution',
    7779            'description' => 'Halt workflow execution immediately. No further nodes will run.',
    … …  
    8991            'category'    => 'logic',
    9092            'phase'       => 'runtime',
     93            'effects'     => [],
    9194            'label'       => 'Set Variable',
    9295            'description' => 'Assign a literal or resolved value to a context variable for use in later nodes.',
    … …  
    111114            'category'    => 'logic',
    112115            'phase'       => 'runtime',
     116            'effects'     => [],
    113117            'label'       => 'For Each Row',
    114118            'description' => 'Iterate over rows from a query result stored in a context variable. Each row\'s columns become available as {{row.column_name}} for the body nodes.',
    … …  
    181185            'category'    => 'logic',
    182186            'phase'       => 'runtime',
     187            'effects'     => [],
    183188            'label'       => 'Switch / Case',
    184189            'description' => 'Evaluate a value against multiple cases and execute the matching branch. Falls through to default_branch if no case matches.',
  • clautron/trunk/includes/primitives/class-primitive-media.php

    r3691441 r3784837  
    2727            'category'    => 'media',
    2828            'phase'       => 'runtime',
     29            'effects'     => [ 'writes_database', 'writes_files', 'external_requests' ],
    2930            'label'       => 'Create Media from URL',
    3031            'description' => 'Download an image from a URL and add it to the WordPress media library. Returns the new attachment ID.',
    … …  
    8182            'category'    => 'media',
    8283            'phase'       => 'runtime',
     84            'effects'     => [ 'writes_database' ],
    8385            'label'       => 'Set Featured Image',
    8486            'description' => 'Set the featured image (thumbnail) of a post to a given attachment ID.',
    … …  
    120122            'category'    => 'media',
    121123            'phase'       => 'runtime',
     124            'effects'     => [],
    122125            'label'       => 'Get Image URL',
    123126            'description' => 'Get the URL of an attachment image at a given size.',
    … …  
    150153            'category'    => 'media',
    151154            'phase'       => 'runtime',
     155            'effects'     => [ 'writes_database', 'writes_files' ],
    152156            'safe'        => false,
    153157            'label'       => 'Handle File Upload',
    … …  
    162166            'handler' => static function ( array $config, CLAUTRON_Workflow_Context $ctx ): int|false {
    163167                $out_var = $config['output_variable'] ?? 'uploaded_attachment_id';
    164                 $fail    = static function () use ( $ctx, $out_var ): false {
     168                $fail    = static function () use ( $ctx, $out_var ): bool {
    165169                    if ( $out_var ) {
    166170                        $ctx->write_variable( $out_var, false );
  • clautron/trunk/includes/primitives/class-primitive-query.php

    r3691441 r3784837  
    3232            'category'    => 'query',
    3333            'phase'       => 'runtime',
     34            'effects'     => [],
    3435            'label'       => 'Query Posts',
    3536            'description' => 'Query WordPress posts with flexible filters. Returns array of post objects (id, title, content, excerpt, status, type, author, date, permalink) stored in a context variable.',
    … …  
    9798            'category'    => 'query',
    9899            'phase'       => 'runtime',
     100            'effects'     => [],
    99101            'label'       => 'Get Post',
    100102            'description' => 'Fetch a single post by ID and store its fields in a context variable.',
    … …  
    133135            'category'    => 'query',
    134136            'phase'       => 'runtime',
     137            'effects'     => [],
    135138            'label'       => 'Count Posts',
    136139            'description' => 'Count posts matching given criteria. Writes integer to output_variable.',
    … …  
    191194            'category'    => 'query',
    192195            'phase'       => 'runtime',
     196            'effects'     => [],
    193197            'label'       => 'Query Users',
    194198            'description' => 'Query WordPress users with flexible filters. Returns array of user objects.',
    … …  
    246250            'category'    => 'query',
    247251            'phase'       => 'runtime',
     252            'effects'     => [],
    248253            'label'       => 'Get User',
    249254            'description' => 'Fetch a single user by ID and store their data in a context variable.',
    … …  
    282287            'category'    => 'query',
    283288            'phase'       => 'runtime',
     289            'effects'     => [],
    284290            'label'       => 'Query Terms',
    285291            'description' => 'Query taxonomy terms. Returns array of term objects.',
  • clautron/trunk/includes/primitives/class-primitive-redirect.php

    r3691441 r3784837  
    2020            'category'    => 'frontend',
    2121            'phase'       => 'compile',
     22            'effects'     => [],
     23            // Browsers cache permanent redirects indefinitely; removing the rule doesn't reach them.
     24            'reversible'  => static fn( array $c ): bool => ! in_array( (int) ( $c['status'] ?? 301 ), [ 301, 308 ], true ),
    2225            'label'       => 'Redirect URL',
    2326            'description' => 'Redirect requests from one path to another. Refuses redirects that would break wp-admin/login/REST access or create a loop.',
  • clautron/trunk/includes/primitives/class-primitive-request.php

    r3691441 r3784837  
    2525            'category'    => 'data',
    2626            'phase'       => 'runtime',
     27            'effects'     => [],
    2728            'safe'        => false,
    2829            'label'       => 'Read Request Parameter',
  • clautron/trunk/includes/primitives/class-primitive-security.php

    r3691441 r3784837  
    33
    44/**
    5  * Security Primitives — abuse throttling.
     5 * Security Primitives — abuse throttling and information-disclosure hardening.
    66 *
    77 * Registered primitives:
    8  *   - rate_limit_check   Fixed-window counter (transient-backed) keyed by an
    9  *                         identifier (defaults to client IP). Use as a
    10  *                         condition to gate login/comment/form actions.
     8 *   - rate_limit_check          Fixed-window counter (transient-backed) keyed by an
     9 *                                identifier (defaults to client IP). Use as a
     10 *                                condition to gate login/comment/form actions.
     11 *   - harden_user_enumeration   Stop anonymous visitors from discovering usernames
     12 *                                (REST users endpoint, author archives, oEmbed
     13 *                                author, login error messages).
     14 *   - security_headers          Send Referrer-Policy, X-Content-Type-Options,
     15 *                                X-Frame-Options and Permissions-Policy on
     16 *                                frontend responses, and HSTS on HTTPS (opt-in).
     17 *   - hide_wp_version           Remove generator tags, mask the WordPress
     18 *                                version in core asset URLs and, opt-in,
     19 *                                delete readme.html.
     20 *
     21 * The hardening primitives hook named static methods rather than closures
     22 * wherever the callback needs no config: add_filter() with the same callable
     23 * and priority is idempotent, so two blueprints declaring the same primitive
     24 * do not stack duplicate callbacks, and verify can check the exact callable
     25 * with has_filter() instead of "something is hooked here".
    1126 */
    1227class CLAUTRON_Primitive_Security extends CLAUTRON_Abstract_Primitive {
    1328
     29    /** Login error codes that reveal whether a username/email exists. */
     30    private const LOGIN_ENUMERATION_CODES = [ 'invalid_username', 'invalid_email', 'incorrect_password' ];
     31
     32    private const REFERRER_POLICIES = [
     33        'no-referrer', 'no-referrer-when-downgrade', 'origin', 'origin-when-cross-origin',
     34        'same-origin', 'strict-origin', 'strict-origin-when-cross-origin', 'unsafe-url',
     35    ];
     36
     37    private const FRAME_OPTIONS = [ 'SAMEORIGIN', 'DENY' ];
     38
     39    private const PERMISSIONS_POLICY = 'camera=(), microphone=()';
     40
     41    /** WordPress version readme.html was last removed for (see remove_core_readme()). */
     42    private const README_OPTION = 'clautron_readme_removed_for';
     43
     44    /** Headers merged from every active security_headers structure; the first one to set a header wins. */
     45    private static array $security_headers = [];
     46
    1447    public static function register_all(): void {
    1548        self::register_rate_limit_check();
     49        self::register_harden_user_enumeration();
     50        self::register_security_headers();
     51        self::register_hide_wp_version();
    1652    }
    1753
    … …  
    2258            'category'    => 'security',
    2359            'phase'       => 'runtime',
     60            'effects'     => [ 'writes_database' ],
    2461            'safe'        => false,
    2562            'label'       => 'Rate Limit Check',
    … …  
    86123        return false;
    87124    }
     125
     126    // ── harden_user_enumeration ──────────────────────────────────────────────
     127
     128    private static function register_harden_user_enumeration(): void {
     129        CLAUTRON_Primitive_Registry::register( 'harden_user_enumeration', [
     130            'category'    => 'security',
     131            'phase'       => 'compile',
     132            'effects'     => [],
     133            'safe'        => false,
     134            'label'       => 'Harden User Enumeration',
     135            'description' => 'Stop anonymous visitors from discovering usernames. Blocks the /wp/v2/users REST endpoints for logged-out requests (logged-in users, including the block editor author picker, keep working); returns 404 for every author archive (?author=N, ?author_name=, /author/slug/ and their feeds), drops the users sitemap and points author links at the homepage; replaces the post author in oEmbed responses with the site name; and gives the same login error for an unknown username and a wrong password. Each part can be switched off individually.',
     136            'schema'      => [
     137                'block_rest_users'        => [ 'type' => 'boolean', 'default' => true, 'description' => '401 on every /wp/v2/users route for logged-out requests.' ],
     138                'disable_author_archives' => [ 'type' => 'boolean', 'default' => true, 'description' => '404 on all author archives for everyone, remove the users sitemap, point author links to the homepage.' ],
     139                'strip_oembed_author'     => [ 'type' => 'boolean', 'default' => true, 'description' => 'oEmbed responses show the site name and homepage instead of the post author.' ],
     140                'generic_login_errors'    => [ 'type' => 'boolean', 'default' => true, 'description' => 'Same login error whether the username/email is unknown or the password is wrong.' ],
     141            ],
     142            'examples'    => [
     143                [],
     144                [ 'disable_author_archives' => false ],
     145            ],
     146            'handler' => static function ( array $config ): void {
     147                if ( $config['block_rest_users'] ?? true ) {
     148                    add_filter( 'rest_request_before_callbacks', [ self::class, 'gate_rest_users' ], 10, 3 );
     149                }
     150                if ( $config['disable_author_archives'] ?? true ) {
     151                    add_filter( 'request', [ self::class, 'block_author_request' ] );
     152                    add_filter( 'wp_sitemaps_add_provider', [ self::class, 'drop_users_sitemap' ], 10, 2 );
     153                    add_filter( 'author_link', [ self::class, 'neutral_author_link' ] );
     154                }
     155                if ( $config['strip_oembed_author'] ?? true ) {
     156                    add_filter( 'oembed_response_data', [ self::class, 'neutral_oembed_author' ] );
     157                }
     158                if ( $config['generic_login_errors'] ?? true ) {
     159                    // After every core authenticator (20) and the spam check (99).
     160                    add_filter( 'authenticate', [ self::class, 'generic_login_error' ], 100 );
     161                }
     162            },
     163            'verify' => static function ( array $config ): array {
     164                $items  = [];
     165                $checks = [
     166                    'block_rest_users'        => [ 'rest_request_before_callbacks', 'gate_rest_users' ],
     167                    'disable_author_archives' => [ 'request', 'block_author_request' ],
     168                    'strip_oembed_author'     => [ 'oembed_response_data', 'neutral_oembed_author' ],
     169                    'generic_login_errors'    => [ 'authenticate', 'generic_login_error' ],
     170                ];
     171                foreach ( $checks as $option => [ $hook, $method ] ) {
     172                    if ( ! ( $config[ $option ] ?? true ) ) {
     173                        continue;
     174                    }
     175                    $items[] = false !== has_filter( $hook, [ self::class, $method ] )
     176                        ? [ 'status' => 'passed', 'label' => "harden_user_enumeration:{$option}" ]
     177                        : [ 'status' => 'failed', 'label' => "harden_user_enumeration:{$option}", 'reason' => "not hooked on {$hook}." ];
     178                }
     179
     180                if ( $config['block_rest_users'] ?? true ) {
     181                    $status  = self::anonymous_rest_status( '/wp/v2/users' );
     182                    $items[] = in_array( $status, [ 401, 403 ], true )
     183                        ? [ 'status' => 'passed', 'label' => "anonymous GET /wp/v2/users → {$status}" ]
     184                        : [ 'status' => 'failed', 'label' => 'anonymous GET /wp/v2/users', 'reason' => "expected 401, got {$status}." ];
     185                }
     186
     187                if ( $config['disable_author_archives'] ?? true ) {
     188                    $vars    = self::author_query_vars( [ 'author' => '1' ], false );
     189                    $items[] = ( $vars['error'] ?? '' ) === '404'
     190                        ? [ 'status' => 'passed', 'label' => '?author=1 resolves to 404' ]
     191                        : [ 'status' => 'failed', 'label' => '?author=1', 'reason' => 'author query was not turned into a 404.' ];
     192                }
     193
     194                return $items;
     195            },
     196        ] );
     197    }
     198
     199    /**
     200     * rest_request_before_callbacks: refuse every route served by the core
     201     * users controller to logged-out requests.
     202     *
     203     * Keyed on the controller that matched, not on the URL: WordPress matches
     204     * routes case-insensitively (/wp/v2/USERS works) and also accepts
     205     * ?rest_route=, so a path check would have to replicate the router to be
     206     * airtight. Whatever spelling reached the users controller is caught here.
     207     */
     208    public static function gate_rest_users( mixed $response, array $handler, WP_REST_Request $request ): mixed {
     209        $callback = $handler['callback'] ?? null;
     210        if ( ! is_array( $callback ) || ! ( $callback[0] ?? null ) instanceof WP_REST_Users_Controller ) {
     211            return $response;
     212        }
     213        if ( is_user_logged_in() ) {
     214            return $response;
     215        }
     216        return new WP_Error(
     217            'rest_forbidden',
     218            __( 'Sorry, you are not allowed to do that.', 'clautron' ),
     219            [ 'status' => rest_authorization_required_code() ]
     220        );
     221    }
     222
     223    /** request filter: wiring for author_query_vars(). */
     224    public static function block_author_request( array $query_vars ): array {
     225        return self::author_query_vars( $query_vars, is_admin() );
     226    }
     227
     228    /**
     229     * Replace any author-archive query with a plain 404. Public for unit
     230     * testing (see CLAUTRON_Primitive_WordPress::build_cpt_args()).
     231     *
     232     * Runs on `request`, before WP_Query exists, so every way of asking for an
     233     * author archive is covered by one check: ?author=N, ?author_name=slug,
     234     * /author/slug/ (rewrite sets author_name) and their /feed/ variants.
     235     * Blocking only the canonical redirect is not enough: an author without
     236     * published posts gets no redirect, but the archive still prints the slug
     237     * in <title> and body_class.
     238     *
     239     * Left alone:
     240     *   - wp-admin, because edit.php?author=N calls wp() and goes through this
     241     *     same filter to filter the posts list;
     242     *   - REST requests, because /wp-json/wp/v2/posts?author=N is a legitimate
     243     *     filter and `author` is a public query var.
     244     */
     245    public static function author_query_vars( array $query_vars, bool $in_admin ): array {
     246        if ( $in_admin || isset( $query_vars['rest_route'] ) ) {
     247            return $query_vars;
     248        }
     249        if ( empty( $query_vars['author'] ) && empty( $query_vars['author_name'] ) ) {
     250            return $query_vars;
     251        }
     252        // Same shape WP::parse_request() produces for an unmatched URL, so
     253        // WP::send_headers() sends the 404 status and nocache headers itself.
     254        return [ 'error' => '404' ];
     255    }
     256
     257    /** wp_sitemaps_add_provider: the users sitemap only lists author archive URLs. */
     258    public static function drop_users_sitemap( mixed $provider, string $name ): mixed {
     259        return 'users' === $name ? false : $provider;
     260    }
     261
     262    /** author_link: author archives are gone, and the link itself carries the slug. */
     263    public static function neutral_author_link(): string {
     264        return home_url( '/' );
     265    }
     266
     267    /**
     268     * oembed_response_data: use the values core itself falls back to when a
     269     * post has no author, instead of display_name + author archive URL.
     270     */
     271    public static function neutral_oembed_author( array $data ): array {
     272        if ( isset( $data['author_name'] ) || isset( $data['author_url'] ) ) {
     273            $data['author_name'] = get_bloginfo( 'name' );
     274            $data['author_url']  = get_home_url();
     275        }
     276        return $data;
     277    }
     278
     279    /**
     280     * authenticate: collapse "unknown username", "unknown email" and "wrong
     281     * password" into one error.
     282     *
     283     * Reuses core's own 'invalidcombo' code: it is already in wp-login.php's
     284     * shake list, and it keeps wp-login.php from pre-filling the username
     285     * field only on a wrong password, which would otherwise leak the same
     286     * information through the form instead of the message.
     287     */
     288    public static function generic_login_error( mixed $user ): mixed {
     289        if ( ! is_wp_error( $user ) || ! array_intersect( $user->get_error_codes(), self::LOGIN_ENUMERATION_CODES ) ) {
     290            return $user;
     291        }
     292        return new WP_Error(
     293            'invalidcombo',
     294            __( '<strong>Error:</strong> The username, email address or password is incorrect.', 'clautron' )
     295        );
     296    }
     297
     298    /** Dispatch a GET as a logged-out visitor and return the HTTP status. */
     299    private static function anonymous_rest_status( string $route ): int {
     300        $previous_user = get_current_user_id();
     301        wp_set_current_user( 0 );
     302        try {
     303            return rest_do_request( new WP_REST_Request( 'GET', $route ) )->get_status();
     304        } finally {
     305            wp_set_current_user( $previous_user );
     306        }
     307    }
     308
     309    // ── security_headers ─────────────────────────────────────────────────────
     310
     311    private static function register_security_headers(): void {
     312        CLAUTRON_Primitive_Registry::register( 'security_headers', [
     313            'category'    => 'security',
     314            'phase'       => 'compile',
     315            'effects'     => [],
     316            // Browsers keep honouring HSTS for max-age after the header stops.
     317            'reversible'  => static fn( array $c ): bool => (int) ( $c['hsts_max_age'] ?? 0 ) <= 0,
     318            'safe'        => false,
     319            'label'       => 'Security Headers',
     320            'description' => 'Send Referrer-Policy, X-Content-Type-Options, X-Frame-Options and Permissions-Policy on frontend responses, plus Strict-Transport-Security (HSTS) on HTTPS when opted in. A header already set by another plugin through wp_headers is left as is. Headers are sent from PHP, so pages served straight from a page cache or CDN without running WordPress will not carry them; verify loads the homepage to detect that.',
     321            'schema'      => [
     322                'referrer_policy'         => [ 'type' => 'string',  'default' => 'strict-origin-when-cross-origin', 'description' => 'Any standard Referrer-Policy value. Empty = do not send. strict-origin-when-cross-origin matches what WordPress core sends in wp-admin; no-referrer breaks YouTube embeds and referral analytics.' ],
     323                'content_type_options'    => [ 'type' => 'boolean', 'default' => true, 'description' => 'Send X-Content-Type-Options: nosniff.' ],
     324                'frame_options'           => [ 'type' => 'string',  'default' => 'SAMEORIGIN', 'description' => 'SAMEORIGIN, DENY, or empty to not send. SAMEORIGIN keeps the Customizer and page-builder previews working.' ],
     325                'permissions_policy'      => [ 'type' => 'string',  'default' => self::PERMISSIONS_POLICY, 'description' => 'Permissions-Policy value, e.g. "camera=(), microphone=(), geolocation=(self)". Empty = do not send; an invalid value is not sent. The default turns off camera and microphone only: blocking geolocation would break store/branch locators, and payment would break Apple Pay / Google Pay buttons.' ],
     326                'hsts_max_age'            => [ 'type' => 'integer', 'default' => 0, 'description' => 'Opt-in Strict-Transport-Security max-age in seconds (31536000 = one year). 0 = do not send. Only sent over HTTPS. Browsers then refuse plain HTTP for that long and deactivating does not undo it, so turn it on only when the whole site works over HTTPS.' ],
     327                'hsts_include_subdomains' => [ 'type' => 'boolean', 'default' => false, 'description' => 'Add includeSubDomains to HSTS. Only when every subdomain serves HTTPS.' ],
     328            ],
     329            'examples'    => [
     330                [],
     331                [ 'permissions_policy' => 'camera=(), microphone=(), geolocation=(self)', 'hsts_max_age' => 31536000 ],
     332            ],
     333            'handler' => static function ( array $config ): void {
     334                $headers = self::build_security_headers( $config );
     335                if ( ! $headers ) {
     336                    return;
     337                }
     338                self::$security_headers = self::merge_headers( self::$security_headers, $headers );
     339                add_filter( 'wp_headers', [ self::class, 'send_security_headers' ] );
     340            },
     341            'verify' => static function ( array $config ): array {
     342                $expected = self::build_security_headers( $config );
     343                if ( ! $expected ) {
     344                    return [];
     345                }
     346                if ( false === has_filter( 'wp_headers', [ self::class, 'send_security_headers' ] ) ) {
     347                    return [ [ 'status' => 'failed', 'label' => 'security_headers', 'reason' => 'not hooked on wp_headers.' ] ];
     348                }
     349
     350                $items = [];
     351                foreach ( $expected as $name => $value ) {
     352                    if ( ( self::$security_headers[ $name ] ?? null ) !== $value ) {
     353                        $items[] = [ 'status' => 'warning', 'label' => "security_headers:{$name}", 'reason' => 'another security_headers capability already sets it to "' . ( self::$security_headers[ $name ] ?? '' ) . '"; the first one wins.' ];
     354                    }
     355                }
     356
     357                $https = 'https' === wp_parse_url( home_url(), PHP_URL_SCHEME );
     358                if ( isset( $expected['Strict-Transport-Security'] ) && ! $https ) {
     359                    $items[] = [ 'status' => 'warning', 'label' => 'security_headers:Strict-Transport-Security', 'reason' => 'HSTS is only sent over HTTPS and this site\'s address is http://.' ];
     360                    unset( $expected['Strict-Transport-Security'] );
     361                }
     362
     363                $response = wp_remote_get( home_url( '/' ), [ 'timeout' => 5, 'redirection' => 3 ] );
     364                if ( is_wp_error( $response ) ) {
     365                    $items[] = [ 'status' => 'warning', 'label' => 'security_headers:live', 'reason' => 'could not load the homepage to confirm the headers reach visitors: ' . $response->get_error_message() ];
     366                    return $items;
     367                }
     368                foreach ( $expected as $name => $value ) {
     369                    $items[] = '' !== wp_remote_retrieve_header( $response, $name )
     370                        ? [ 'status' => 'passed', 'label' => "{$name} on homepage" ]
     371                        : [ 'status' => 'warning', 'label' => "{$name} on homepage", 'reason' => 'set by WordPress but missing from the live response; a page cache, CDN or proxy is serving the page without PHP or stripping the header. Set it at that layer too.' ];
     372                }
     373                return $items;
     374            },
     375        ] );
     376    }
     377
     378    /**
     379     * wp_headers: everything active security_headers structures asked for.
     380     * One named callback (not a closure per structure) so verify can check
     381     * that this primitive, not just any plugin, is hooked.
     382     */
     383    public static function send_security_headers( array $headers ): array {
     384        $ours = self::$security_headers;
     385        if ( ! is_ssl() ) {
     386            unset( $ours['Strict-Transport-Security'] );
     387        }
     388        return self::merge_headers( $headers, $ours );
     389    }
     390
     391    /**
     392     * Header name → value from a security_headers config. Invalid values are
     393     * dropped rather than sent, since a malformed Referrer-Policy or
     394     * X-Frame-Options is ignored by browsers anyway. Public for unit testing.
     395     */
     396    public static function build_security_headers( array $config ): array {
     397        $headers = [];
     398
     399        $referrer = strtolower( trim( (string) ( $config['referrer_policy'] ?? 'strict-origin-when-cross-origin' ) ) );
     400        if ( in_array( $referrer, self::REFERRER_POLICIES, true ) ) {
     401            $headers['Referrer-Policy'] = $referrer;
     402        }
     403
     404        if ( $config['content_type_options'] ?? true ) {
     405            $headers['X-Content-Type-Options'] = 'nosniff';
     406        }
     407
     408        $frame = strtoupper( trim( (string) ( $config['frame_options'] ?? 'SAMEORIGIN' ) ) );
     409        if ( in_array( $frame, self::FRAME_OPTIONS, true ) ) {
     410            $headers['X-Frame-Options'] = $frame;
     411        }
     412
     413        $permissions = trim( (string) ( $config['permissions_policy'] ?? self::PERMISSIONS_POLICY ) );
     414        $directive   = '[a-z0-9-]+=(?:\*|\((?:[ \t]*(?:self|src|\*|"https?:\/\/[^"\s]+"))*[ \t]*\))';
     415        if ( preg_match( '/^' . $directive . '(?:[ \t]*,[ \t]*' . $directive . ')*$/D', $permissions ) ) {
     416            $headers['Permissions-Policy'] = $permissions;
     417        }
     418
     419        $max_age = min( 63072000, (int) ( $config['hsts_max_age'] ?? 0 ) );
     420        if ( $max_age > 0 ) {
     421            $headers['Strict-Transport-Security'] = "max-age={$max_age}" . ( empty( $config['hsts_include_subdomains'] ) ? '' : '; includeSubDomains' );
     422        }
     423
     424        return $headers;
     425    }
     426
     427    /**
     428     * Add $ours to $existing without overriding a header someone else already
     429     * set (case-insensitive, like HTTP header names). Overriding could weaken
     430     * a stricter choice, e.g. a security plugin's X-Frame-Options: DENY.
     431     */
     432    public static function merge_headers( array $existing, array $ours ): array {
     433        $taken = array_change_key_case( $existing, CASE_LOWER );
     434        foreach ( $ours as $name => $value ) {
     435            if ( ! isset( $taken[ strtolower( $name ) ] ) ) {
     436                $existing[ $name ] = $value;
     437            }
     438        }
     439        return $existing;
     440    }
     441
     442    // ── hide_wp_version ──────────────────────────────────────────────────────
     443
     444    private static function register_hide_wp_version(): void {
     445        CLAUTRON_Primitive_Registry::register( 'hide_wp_version', [
     446            'category'    => 'security',
     447            'phase'       => 'compile',
     448            'effects'     => static fn( array $c ): array => empty( $c['remove_readme'] ) ? [] : [ 'writes_files', 'writes_database' ],
     449            'effects_max' => [ 'writes_files', 'writes_database' ],
     450            'safe'        => false,
     451            'label'       => 'Hide WordPress Version',
     452            'description' => 'Stop advertising the WordPress version. Removes the generator meta tag and the feed <generator> element (which also drops WooCommerce\'s generator line), turns off Elementor\'s generator tag without changing its saved setting, and replaces ?ver=<WordPress version> on asset URLs with a site-specific hash that still changes on every update, so browser caches are still refreshed. readme.html in the site root also states the version, but the web server serves it without WordPress, so nothing above hides it: set remove_readme to delete it (opt-in, a file deletion that deactivating does not undo).',
     453            'schema'      => [
     454                'remove_generator'    => [ 'type' => 'boolean', 'default' => true, 'description' => 'Remove generator tags (WordPress, WooCommerce, Elementor) from pages and feeds.' ],
     455                'mask_asset_versions' => [ 'type' => 'boolean', 'default' => true, 'description' => 'Replace ?ver=<WordPress version> on script/style URLs with a hash of it.' ],
     456                'remove_readme'       => [ 'type' => 'boolean', 'default' => false, 'description' => 'Delete readme.html from the site root, and again after every WordPress update (which restores it). Works on any web server. Deactivating does not bring the file back; the next WordPress update does.' ],
     457            ],
     458            'examples'    => [
     459                [],
     460                [ 'remove_readme' => true ],
     461            ],
     462            'handler' => static function ( array $config ): void {
     463                if ( ! empty( $config['remove_readme'] ) ) {
     464                    add_action( 'init', [ self::class, 'maybe_remove_core_readme' ] );
     465                }
     466                if ( $config['remove_generator'] ?? true ) {
     467                    add_filter( 'the_generator', '__return_empty_string' );
     468                    // Elementor prints its own tag on wp_head unless this option is '1'.
     469                    // pre_option_ overrides it for as long as the feature is active and
     470                    // leaves the stored value untouched when it is deactivated.
     471                    add_filter( 'pre_option_elementor_meta_generator_tag', [ self::class, 'elementor_generator_off' ] );
     472                }
     473                if ( $config['mask_asset_versions'] ?? true ) {
     474                    add_filter( 'script_loader_src', [ self::class, 'mask_core_version' ] );
     475                    add_filter( 'style_loader_src', [ self::class, 'mask_core_version' ] );
     476                }
     477            },
     478            'verify' => static function ( array $config ): array {
     479                $items = [];
     480                if ( $config['remove_generator'] ?? true ) {
     481                    $items[] = false !== has_filter( 'the_generator', '__return_empty_string' )
     482                        ? [ 'status' => 'passed', 'label' => 'hide_wp_version:remove_generator' ]
     483                        : [ 'status' => 'failed', 'label' => 'hide_wp_version:remove_generator', 'reason' => 'not hooked on the_generator.' ];
     484                }
     485                if ( $config['mask_asset_versions'] ?? true ) {
     486                    $version = get_bloginfo( 'version' );
     487                    $masked  = self::mask_core_version( includes_url( 'js/jquery/jquery.min.js?ver=' . $version ) );
     488                    $items[] = false !== has_filter( 'script_loader_src', [ self::class, 'mask_core_version' ] ) && ! str_contains( $masked, $version )
     489                        ? [ 'status' => 'passed', 'label' => 'hide_wp_version:mask_asset_versions' ]
     490                        : [ 'status' => 'failed', 'label' => 'hide_wp_version:mask_asset_versions', 'reason' => 'asset URLs still carry the WordPress version.' ];
     491                }
     492                if ( ! empty( $config['remove_readme'] ) ) {
     493                    $items[] = ! file_exists( ABSPATH . 'readme.html' )
     494                        ? [ 'status' => 'passed', 'label' => 'hide_wp_version:remove_readme' ]
     495                        : [ 'status' => 'failed', 'label' => 'hide_wp_version:remove_readme', 'reason' => 'readme.html is still in the site root; WordPress could not delete it (file permissions). Delete it by hand or deny it at the web server.' ];
     496                }
     497                return $items;
     498            },
     499        ] );
     500    }
     501
     502    /** init: wiring for remove_core_readme(). */
     503    public static function maybe_remove_core_readme(): void {
     504        self::remove_core_readme( ABSPATH . 'readme.html', (string) get_bloginfo( 'version' ) );
     505    }
     506
     507    /**
     508     * Delete $path once per WordPress version. Every core update (one-click,
     509     * WP-CLI or files copied by hand) puts readme.html back and changes the
     510     * version, so keying on the version catches all of them for the price of
     511     * one autoloaded option read per request, not a file stat. A failed
     512     * delete is not recorded, so it is retried. Public for unit testing.
     513     */
     514    public static function remove_core_readme( string $path, string $version ): bool {
     515        if ( get_option( self::README_OPTION ) === $version ) {
     516            return false;
     517        }
     518        if ( file_exists( $path ) ) {
     519            wp_delete_file( $path );
     520        }
     521        if ( file_exists( $path ) ) {
     522            return false;
     523        }
     524        update_option( self::README_OPTION, $version, true );
     525        return true;
     526    }
     527
     528    /** pre_option_elementor_meta_generator_tag: '1' is Elementor's "disabled". */
     529    public static function elementor_generator_off(): string {
     530        return '1';
     531    }
     532
     533    /**
     534     * script_loader_src / style_loader_src: swap ver=<WordPress version> for a
     535     * salted hash of it. Assets enqueued without an explicit version get the
     536     * WordPress version by default, so they are masked too. Other versions
     537     * (a plugin's own) are left alone. Public for unit testing.
     538     */
     539    public static function mask_core_version( mixed $src ): mixed {
     540        if ( ! is_string( $src ) || '' === $src ) {
     541            return $src;
     542        }
     543        $version = get_bloginfo( 'version' );
     544        parse_str( (string) wp_parse_url( $src, PHP_URL_QUERY ), $query );
     545        if ( ( $query['ver'] ?? null ) !== $version ) {
     546            return $src;
     547        }
     548        return add_query_arg( 'ver', substr( wp_hash( $version ), 0, 8 ), $src );
     549    }
    88550}
  • clautron/trunk/includes/primitives/class-primitive-sitemap.php

    r3691441 r3784837  
    3030            'category'    => 'frontend',
    3131            'phase'       => 'compile',
     32            'effects'     => [],
    3233            'safe'        => false,
    3334            'label'       => 'Register XML Sitemap',
  • clautron/trunk/includes/primitives/class-primitive-taxonomy.php

    r3582869 r3784837  
    2828            'category'    => 'taxonomy',
    2929            'phase'       => 'runtime',
     30            'effects'     => [ 'writes_database' ],
    3031            'label'       => 'Set Post Terms',
    3132            'description' => 'Assign taxonomy terms to a post. Accepts term IDs, slugs, or names.',
    … …  
    7273            'category'    => 'taxonomy',
    7374            'phase'       => 'runtime',
     75            'effects'     => [],
    7476            'label'       => 'Get Post Terms',
    7577            'description' => 'Get all terms assigned to a post for a given taxonomy.',
    … …  
    117119            'category'    => 'taxonomy',
    118120            'phase'       => 'runtime',
     121            'effects'     => [ 'writes_database' ],
    119122            'label'       => 'Create Term',
    120123            'description' => 'Create a new taxonomy term if it does not already exist. Returns the term ID.',
    … …  
    176179            'category'    => 'taxonomy',
    177180            'phase'       => 'runtime',
     181            'effects'     => [],
    178182            'label'       => 'Term Exists',
    179183            'description' => 'Check if a term exists in a taxonomy. Writes term ID (int) or 0 if not found.',
  • clautron/trunk/includes/primitives/class-primitive-transform.php

    r3582869 r3784837  
    6161            'category'    => 'data',
    6262            'phase'       => 'runtime',
     63            'effects'     => [],
    6364            'label'       => 'Parse JSON',
    6465            'description' => 'Decode a JSON string into an associative array and store it in a context variable.',
    … …  
    9091            'category'    => 'data',
    9192            'phase'       => 'runtime',
     93            'effects'     => [],
    9294            'label'       => 'To JSON',
    9395            'description' => 'Encode a context variable as a JSON string and store it in another variable.',
    … …  
    119121            'category'    => 'data',
    120122            'phase'       => 'runtime',
     123            'effects'     => [],
    121124            'label'       => 'Map Fields',
    122125            'description' => 'Remap an array of objects to a new shape. Each output key\'s value is a source field name or a {{expr}} template evaluated per item.',
    … …  
    168171            'category'    => 'data',
    169172            'phase'       => 'runtime',
     173            'effects'     => [],
    170174            'label'       => 'Filter Array',
    171175            'description' => 'Filter an array of objects by a field condition. Supported operators: eq, neq, gt, gte, lt, lte, contains, not_contains, empty, not_empty.',
    … …  
    214218            'category'    => 'data',
    215219            'phase'       => 'runtime',
     220            'effects'     => [],
    216221            'label'       => 'Array Pluck',
    217222            'description' => 'Extract a single field from each item in an array, producing a flat array of values.',
    … …  
    256261            'category'    => 'data',
    257262            'phase'       => 'runtime',
     263            'effects'     => [],
    258264            'label'       => 'Array Merge',
    259265            'description' => 'Merge multiple array variables into one combined array.',
    … …  
    290296            'category'    => 'data',
    291297            'phase'       => 'runtime',
     298            'effects'     => [],
    292299            'label'       => 'Array Unique',
    293300            'description' => 'Remove duplicate values from a flat array and reindex.',
    … …  
    319326            'category'    => 'data',
    320327            'phase'       => 'runtime',
     328            'effects'     => [],
    321329            'label'       => 'Array Sort',
    322330            'description' => 'Sort an array. If field is set, sort an array of objects by that field. Otherwise sort a flat array.',
    … …  
    375383            'category'    => 'data',
    376384            'phase'       => 'runtime',
     385            'effects'     => [],
    377386            'label'       => 'Array Slice',
    378387            'description' => 'Return a portion of an array starting at offset for a given length.',
    … …  
    408417            'category'    => 'data',
    409418            'phase'       => 'runtime',
     419            'effects'     => [],
    410420            'label'       => 'Count Array',
    411421            'description' => 'Count the number of items in an array variable and store the integer.',
    … …  
    437447            'category'    => 'data',
    438448            'phase'       => 'runtime',
     449            'effects'     => [],
    439450            'label'       => 'String Format',
    440451            'description' => 'Render a template string with {{variable}} placeholders and store the result.',
    … …  
    465476            'category'    => 'data',
    466477            'phase'       => 'runtime',
     478            'effects'     => [],
    467479            'label'       => 'String Truncate',
    468480            'description' => 'Truncate a string to a maximum character length, appending a suffix when truncated.',
    … …  
    501513            'category'    => 'data',
    502514            'phase'       => 'runtime',
     515            'effects'     => [],
    503516            'label'       => 'String Replace',
    504517            'description' => 'Find and replace a substring within a string.',
    … …  
    533546            'category'    => 'data',
    534547            'phase'       => 'runtime',
     548            'effects'     => [],
    535549            'label'       => 'String Split',
    536550            'description' => 'Split a string by a delimiter into an array. Empty elements are removed.',
    … …  
    564578            'category'    => 'data',
    565579            'phase'       => 'runtime',
     580            'effects'     => [],
    566581            'label'       => 'String Join',
    567582            'description' => 'Join an array of strings into a single string using a glue separator.',
    … …  
    595610            'category'    => 'data',
    596611            'phase'       => 'runtime',
     612            'effects'     => [],
    597613            'label'       => 'Date Format',
    598614            'description' => 'Format a date string or timestamp using a PHP date format string.',
    … …  
    626642            'category'    => 'data',
    627643            'phase'       => 'runtime',
     644            'effects'     => [],
    628645            'label'       => 'Date Diff',
    629646            'description' => 'Calculate the absolute difference between two dates in the requested unit.',
    … …  
    675692            'category'    => 'data',
    676693            'phase'       => 'runtime',
     694            'effects'     => [],
    677695            'label'       => 'Date Add',
    678696            'description' => 'Add a specified amount of time to a date and format the result.',
    … …  
    719737            'category'    => 'data',
    720738            'phase'       => 'runtime',
     739            'effects'     => [],
    721740            'label'       => 'Slugify',
    722741            'description' => 'Convert a string to a URL-friendly slug using WordPress sanitize_title().',
    … …  
    747766            'category'    => 'data',
    748767            'phase'       => 'runtime',
     768            'effects'     => [],
    749769            'label'       => 'Math Calculate',
    750770            'description' => 'Perform a numeric math operation between two values.',
  • clautron/trunk/includes/primitives/class-primitive-ui.php

    r3691441 r3784837  
    4141            'category'    => 'ui',
    4242            'phase'       => 'compile',
     43            'effects'     => [ 'writes_database' ],
    4344            'label'       => 'Add Metabox',
    4445            'description' => 'Add a custom metabox panel to the post edit screen for one or more post types.',
    … …  
    109110                }
    110111            },
     112            'verify' => static function ( array $config ): array {
     113                $box_id = sanitize_key( $config['id'] ?? '' );
     114                if ( ! $box_id ) {
     115                    return [];
     116                }
     117                // Handler registers with the 'clautron-' prefix (see above) —
     118                // the check must look for the same id it was actually
     119                // registered under.
     120                $mbid = 'clautron-' . $box_id;
     121                $pts  = (array) ( $config['post_types'] ?? [ 'post' ] );
     122                $pt   = $pts[0] ?? 'post';
     123                if ( ! is_admin() ) {
     124                    return [ [ 'status' => 'warning', 'label' => "add_metabox:{$mbid}", 'reason' => 'skipped (requires admin context; will work correctly on post edit screens).' ] ];
     125                }
     126                $hook_name = 'add_meta_boxes_' . $pt;
     127                do_action( $hook_name, null );
     128                do_action( 'add_meta_boxes', $pt, null );
     129                global $wp_meta_boxes;
     130                $found = false;
     131                foreach ( $wp_meta_boxes[ $pt ] ?? [] as $context_boxes ) {
     132                    foreach ( $context_boxes as $priority_boxes ) {
     133                        if ( isset( $priority_boxes[ $mbid ] ) ) {
     134                            $found = true;
     135                            break 2;
     136                        }
     137                    }
     138                }
     139                if ( $found ) {
     140                    return [ [ 'status' => 'passed', 'label' => "add_metabox:{$mbid}" ] ];
     141                }
     142                return [ [ 'status' => 'failed', 'label' => "add_metabox:{$mbid}", 'reason' => "not found in wp_meta_boxes for post type '{$pt}'." ] ];
     143            },
    111144        ] );
    112145    }
    … …  
    118151            'category'    => 'ui',
    119152            'phase'       => 'compile',
     153            'effects'     => [],
    120154            'label'       => 'Add Admin Column',
    121155            'description' => 'Add a custom column to the WordPress admin list table for a post type.',
    … …  
    215249                }
    216250            },
     251            'verify' => static function ( array $config ): array {
     252                $pts   = (array) ( $config['post_types'] ?? [] );
     253                $items = [];
     254                foreach ( $pts as $pt ) {
     255                    $filter = "manage_{$pt}_posts_columns";
     256                    if ( has_filter( $filter ) ) {
     257                        $items[] = [ 'status' => 'passed', 'label' => "add_admin_column:{$pt}" ];
     258                    } else {
     259                        $items[] = [ 'status' => 'failed', 'label' => "add_admin_column:{$pt}", 'reason' => "filter '{$filter}' not hooked." ];
     260                    }
     261                }
     262                return $items;
     263            },
    217264        ] );
    218265    }
    … …  
    224271            'category'    => 'ui',
    225272            'phase'       => 'compile',
     273            'effects'     => [],
    226274            'label'       => 'Add Admin Filter',
    227275            'description' => 'Add a custom dropdown filter to the WordPress admin post list table.',
    … …  
    325373            'category'    => 'ui',
    326374            'phase'       => 'runtime',
     375            'effects'     => [ 'executes_code' ],
     376            'safe'        => false,
    327377            'label'       => 'Add Admin Notice',
    328378            'description' => 'Display an admin notice (info, success, warning, error).',
    … …  
    359409            'category'    => 'ui',
    360410            'phase'       => 'compile',
     411            'effects'     => [ 'writes_database' ],
    361412            'label'       => 'Add Bulk Action',
    362413            'description' => 'Add a custom bulk action to a post type list table.',
    … …  
    423474            'category'    => 'ui',
    424475            'phase'       => 'compile',
     476            'effects'     => [],
    425477            'label'       => 'Add Report Page',
    426478            'description' => 'Add a stats/report section to the Clautron Reports admin page. Renders metric cards with post counts based on configurable queries.',
    … …  
    9861038            'category'    => 'ui',
    9871039            'phase'       => 'compile',
     1040            'effects'     => [ 'writes_database' ],
    9881041            'label'       => 'Add Row Action',
    9891042            'description' => 'Add a custom action link to post row actions in the list table (e.g. Clone).',
    … …  
    10411094                } );
    10421095            },
     1096            'verify' => static function ( array $config ): array {
     1097                $pts   = (array) ( $config['post_types'] ?? [] );
     1098                $items = [];
     1099                foreach ( $pts as $pt ) {
     1100                    if ( has_filter( 'post_row_actions' ) || has_filter( "{$pt}_row_actions" ) ) {
     1101                        $items[] = [ 'status' => 'passed', 'label' => "add_row_action:{$pt}" ];
     1102                    } else {
     1103                        $items[] = [ 'status' => 'warning', 'label' => "add_row_action:{$pt}", 'reason' => 'row action filter not detected (may still work).' ];
     1104                    }
     1105                }
     1106                return $items;
     1107            },
    10431108        ] );
    10441109    }
    … …  
    11221187            'category'    => 'ui',
    11231188            'phase'       => 'compile',
     1189            'effects'     => [ 'writes_database' ],
    11241190            'label'       => 'Add Options Page',
    11251191            'description' => 'Add a site-wide settings page under wp-admin (not tied to any single post). Uses the same field types as add_metabox\'s fields template: text, textarea, checkbox, select, radio, image, repeater. Each field is saved as its own option: clautron_opt_{menu_slug}_{field_key}.',
  • clautron/trunk/includes/primitives/class-primitive-user.php

    r3582869 r3784837  
    3232            'category'    => 'user',
    3333            'phase'       => 'runtime',
     34            'effects'     => [],
    3435            'label'       => 'Read User Meta',
    3536            'description' => 'Read a user meta field and store the value in a context variable.',
    … …  
    6263            'category'    => 'user',
    6364            'phase'       => 'runtime',
     65            'effects'     => [ 'writes_database' ],
    6466            'label'       => 'Write User Meta',
    6567            'description' => 'Write or update a user meta value. All fields support {{variable}} syntax.',
    … …  
    103105            'category'    => 'user',
    104106            'phase'       => 'runtime',
     107            'effects'     => [ 'writes_database' ],
    105108            'label'       => 'Update User Role',
    106109            'description' => 'Change a user\'s WordPress role. Allowed roles: subscriber, contributor, author, editor. Cannot promote to administrator.',
    … …  
    152155            'category'    => 'user',
    153156            'phase'       => 'runtime',
     157            'effects'     => [ 'writes_database', 'deletes_data' ],
    154158            'label'       => 'Delete User (Safe)',
    155159            'description' => 'Delete a WordPress user. By default, skipped if the user has any published posts or approved comments. Set force=true to override.',
    … …  
    230234            'category'    => 'user',
    231235            'phase'       => 'runtime',
     236            'effects'     => [],
    232237            'label'       => 'Get Current User',
    233238            'description' => 'Store the currently logged-in user\'s data (id, login, email, display_name, roles) in a context variable.',
    … …  
    270275            'category'    => 'user',
    271276            'phase'       => 'runtime',
     277            'effects'     => [],
    272278            'label'       => 'Check User Capability',
    273279            'description' => 'Check if a user has a specific capability. Writes true/false to output_variable.',
    … …  
    305311            'category'    => 'user',
    306312            'phase'       => 'runtime',
     313            'effects'     => [],
    307314            'label'       => 'Check User Role',
    308315            'description' => 'Check if a user has a specific role. Writes true/false to output_variable.',
  • clautron/trunk/includes/primitives/class-primitive-webhook.php

    r3691441 r3784837  
    3838            'category'    => 'http',
    3939            'phase'       => 'compile',
     40            'effects'     => [ 'writes_database', 'changes_schema' ],
    4041            'safe'        => false,
    4142            'label'       => 'Register Payment Webhook',
  • clautron/trunk/includes/primitives/class-primitive-woocommerce.php

    r3691441 r3784837  
    3636            'category'    => 'woocommerce',
    3737            'phase'       => 'runtime',
     38            'effects'     => [],
    3839            'label'       => 'Query Orders',
    3940            'description' => 'Query WooCommerce orders with filters. Returns array of orders (id, status, total, currency, customer_id, customer_email, date_created, items_count) stored in a context variable.',
    … …  
    109110            'category'    => 'woocommerce',
    110111            'phase'       => 'runtime',
     112            'effects'     => [],
    111113            'label'       => 'Query Products',
    112114            'description' => 'Query WooCommerce products with filters. Returns array of products (id, name, sku, price, regular_price, stock_status, stock_quantity, type, status) stored in a context variable.',
    … …  
    180182            'category'    => 'woocommerce',
    181183            'phase'       => 'runtime',
     184            'effects'     => [ 'writes_database', 'external_requests' ],
    182185            'label'       => 'Update Order Status',
    183186            'description' => 'Move a WooCommerce order to another standard status (pending, processing, on-hold, completed, cancelled, refunded, failed). Adds an order note attributing the change to the Clautron workflow. Trashing/deleting orders is intentionally not supported.',
  • clautron/trunk/includes/primitives/class-primitive-wordpress.php

    r3691441 r3784837  
    4646            'category'    => 'wordpress',
    4747            'phase'       => 'compile',
     48            // A custom capability_type grants its caps to administrator, which
     49            // WordPress stores in wp_user_roles: that outlives deactivation.
     50            'effects'     => static fn( array $c ): array => in_array( trim( explode( ',', (string) ( $c['capability_type'] ?? 'post' ) )[0] ), [ 'post', 'page' ], true ) ? [ 'changes_schema' ] : [ 'changes_schema', 'writes_database' ],
     51            'effects_max' => [ 'changes_schema', 'writes_database' ],
    4852            'label'       => 'Register Custom Post Type',
    4953            'description' => 'Register a new custom post type in WordPress.',
    … …  
    7882                } );
    7983            },
     84            'verify' => static function ( array $config ): array {
     85                $pt = $config['post_type'] ?? '';
     86                if ( ! $pt ) {
     87                    return [];
     88                }
     89                if ( get_post_type_object( $pt ) ) {
     90                    return [ [ 'status' => 'passed', 'label' => "register_cpt:{$pt}" ] ];
     91                }
     92                return [ [ 'status' => 'failed', 'label' => "register_cpt:{$pt}", 'reason' => 'post type not registered.' ] ];
     93            },
    8094        ] );
    8195    }
    … …  
    89103     * type they just asked for. Grant the mapped capabilities to
    90104     * administrator; add_cap() is idempotent, safe to call every request.
     105     *
     106     * Only primitive capabilities. edit_post, read_post and delete_post are
     107     * meta capabilities that map_meta_cap() resolves per post; a role must
     108     * never hold them literally. WP_Role::has_cap() can't see them either,
     109     * so granting them would rewrite wp_user_roles on every request, even
     110     * for capability_type "post".
    91111     */
    92112    public static function grant_cpt_capabilities_to_admin( \WP_Post_Type $post_type_obj ): void {
    … …  
    95115            return;
    96116        }
    97         foreach ( (array) $post_type_obj->cap as $cap ) {
     117        $primitive = array_diff_key( (array) $post_type_obj->cap, array_flip( [ 'edit_post', 'read_post', 'delete_post' ] ) );
     118        foreach ( $primitive as $cap ) {
    98119            if ( is_string( $cap ) && ! $role->has_cap( $cap ) ) {
    99120                $role->add_cap( $cap );
    100121            }
    101122        }
     123    }
     124
     125    /**
     126     * Effects of giving a post $status: trash removes it from the site, and
     127     * publishing makes WordPress ping (update services, pingbacks,
     128     * enclosures). An empty status leaves it unchanged. A value with a
     129     * {{placeholder}} is only known at run time (and sanitize_key() turns
     130     * "{{trash}}" into "trash"), so it gets the worst case. Core re-pinging
     131     * when an already published post is saved again is not counted.
     132     */
     133    public static function post_status_effects( string $status ): array {
     134        if ( str_contains( $status, '{{' ) ) {
     135            return [ 'writes_database', 'deletes_data', 'external_requests' ];
     136        }
     137        return [
     138            'trash'   => [ 'writes_database', 'deletes_data' ],
     139            'publish' => [ 'writes_database', 'external_requests' ],
     140        ][ sanitize_key( $status ) ] ?? [ 'writes_database' ];
    102141    }
    103142
    … …  
    142181            'category'    => 'wordpress',
    143182            'phase'       => 'compile',
     183            'effects'     => [ 'changes_schema' ],
    144184            'label'       => 'Register Taxonomy',
    145185            'description' => 'Register a custom taxonomy for one or more post types.',
    … …  
    167207                    register_taxonomy( $taxonomy, $post_types, self::build_taxonomy_args( $config, $label, $taxonomy ) );
    168208                } );
     209            },
     210            'verify' => static function ( array $config ): array {
     211                $tax = $config['taxonomy'] ?? '';
     212                if ( ! $tax ) {
     213                    return [];
     214                }
     215                if ( taxonomy_exists( $tax ) ) {
     216                    return [ [ 'status' => 'passed', 'label' => "register_taxonomy:{$tax}" ] ];
     217                }
     218                return [ [ 'status' => 'failed', 'label' => "register_taxonomy:{$tax}", 'reason' => 'taxonomy not registered.' ] ];
    169219            },
    170220        ] );
    … …  
    194244            'category'    => 'wordpress',
    195245            'phase'       => 'compile',
     246            'effects'     => [ 'changes_schema' ],
    196247            'label'       => 'Register Meta Field',
    197248            'description' => 'Register a meta field for posts, users, or terms with type and schema.',
    … …  
    231282                } );
    232283            },
     284            'verify' => static function ( array $config ): array {
     285                $key = $config['meta_key'] ?? '';
     286                if ( ! $key ) {
     287                    return [];
     288                }
     289                $pts        = (array) ( $config['post_types'] ?? [ 'post' ] );
     290                $registered = registered_meta_key_exists( 'post', $key, $pts[0] ?? '' );
     291                if ( $registered ) {
     292                    return [ [ 'status' => 'passed', 'label' => "register_meta:{$key}" ] ];
     293                }
     294                return [ [ 'status' => 'warning', 'label' => "register_meta:{$key}", 'reason' => 'not found in registered meta (may still work via update_post_meta).' ] ];
     295            },
    233296        ] );
    234297    }
    … …  
    240303            'category'    => 'wordpress',
    241304            'phase'       => 'compile',
     305            'effects'     => [ 'changes_schema' ],
    242306            'safe'        => false,
    243307            'label'       => 'Register REST Route',
    … …  
    286350                } );
    287351            },
     352            'verify' => static function ( array $config ): array {
     353                $route = $config['route'] ?? '';
     354                if ( ! $route ) {
     355                    return [];
     356                }
     357                $routes    = rest_get_server()->get_routes();
     358                $full_path = '/clautron/v1/' . ltrim( $route, '/' );
     359                if ( isset( $routes[ $full_path ] ) ) {
     360                    return [ [ 'status' => 'passed', 'label' => "register_rest_route:{$route}" ] ];
     361                }
     362                return [ [ 'status' => 'warning', 'label' => "register_rest_route:{$route}", 'reason' => 'not found in registered routes (REST server may not be initialized yet).' ] ];
     363            },
    288364        ] );
    289365    }
    … …  
    295371            'category'    => 'wordpress',
    296372            'phase'       => 'compile',
     373            'effects'     => [ 'writes_database' ],
    297374            'label'       => 'Schedule Post Expiry',
    298375            'description' => 'Automatically change post status on its expiry date (stored in meta).',
    … …  
    353430            'category'    => 'wordpress',
    354431            'phase'       => 'runtime',
     432            // The handler only allows draft/publish/pending/private (anything else
     433            // becomes draft), so a new post can be published but never trashed.
     434            'effects'     => static fn( array $c ): array => array_values( array_diff( self::post_status_effects( (string) ( $c['post_status'] ?? 'draft' ) ), [ 'deletes_data' ] ) ),
     435            'effects_max' => [ 'writes_database', 'external_requests' ],
     436            'safe'        => false,
    355437            'label'       => 'Create Post',
    356438            'description' => 'Create a new WordPress post (any type / status). Supports deduplication by post meta key + value to avoid creating duplicate posts across job runs.',
    … …  
    450532            'category'    => 'wordpress',
    451533            'phase'       => 'runtime',
     534            'effects'     => static fn( array $c ): array => self::post_status_effects( (string) ( $c['post_status'] ?? '' ) ),
     535            'effects_max' => [ 'writes_database', 'deletes_data', 'external_requests' ],
    452536            'label'       => 'Update Post',
    453537            'description' => 'Update fields on an existing WordPress post. Only provided fields are changed.',
    … …  
    523607            'category'    => 'wordpress',
    524608            'phase'       => 'runtime',
     609            'effects'     => [ 'writes_database', 'deletes_data' ],
    525610            'label'       => 'Delete Post',
    526611            'description' => 'Move a post to trash (force=false) or permanently delete it (force=true).',
    … …  
    562647            'category'    => 'wordpress',
    563648            'phase'       => 'runtime',
     649            'effects'     => static fn( array $c ): array => self::post_status_effects( (string) ( $c['status'] ?? '' ) ),
     650            'effects_max' => [ 'writes_database', 'deletes_data', 'external_requests' ],
    564651            'label'       => 'Set Post Status',
    565652            'description' => 'Change the status of a post (draft, publish, pending, private, trash).',
    … …  
    602689            'category'    => 'wordpress',
    603690            'phase'       => 'runtime',
     691            'effects'     => [],
    604692            'label'       => 'Check WooCommerce HPOS',
    605693            'description' => 'Detects whether WooCommerce High Performance Order Storage (HPOS) is active. Stores true/false in a context variable. Use with if_else to branch between HPOS and legacy wp_posts order queries.',
    … …  
    625713            'category'    => 'wordpress',
    626714            'phase'       => 'runtime',
     715            'effects'     => [ 'writes_database' ],
    627716            'label'       => 'Schedule Single Event',
    628717            'description' => 'Schedule a one-time WP-Cron event to fire at a future time. The hook name must start with "clautron_" for safety. The arg is resolved as an integer (post/order ID).',
  • clautron/trunk/includes/primitives/class-primitive-workflow.php

    r3582869 r3784837  
    2828            'category'    => 'workflow',
    2929            'phase'       => 'runtime',
     30            'effects'     => [],
    3031            'label'       => 'Validate Field',
    3132            'description' => 'Validate a value against rules (required, min_length, max_length, email, url, numeric, regex). Writes true/false to output_variable. Writes error message to error_variable if set.',
    … …  
    105106            'category'    => 'workflow',
    106107            'phase'       => 'runtime',
     108            'effects'     => [ 'writes_database' ],
    107109            'label'       => 'Request Review',
    108110            'description' => 'Flag a post for editorial review by setting its status to \'pending\' and storing a review request note in meta.',
    … …  
    144146            'category'    => 'workflow',
    145147            'phase'       => 'runtime',
     148            'effects'     => [ 'writes_database', 'deletes_data', 'external_requests' ], // deletes the review note; publishing pings
    146149            'label'       => 'Approve Content',
    147150            'description' => 'Approve content by publishing a pending post and clearing the review note.',
    … …  
    177180            'category'    => 'workflow',
    178181            'phase'       => 'runtime',
     182            'effects'     => [ 'writes_database' ],
    179183            'label'       => 'Reject Content',
    180184            'description' => 'Reject content by reverting a pending post to draft and storing a rejection reason.',
  • clautron/trunk/includes/registry/class-primitive-registry.php

    r3582869 r3784837  
    3636     *   @type string   $description One-sentence description for the AI prompt catalog.
    3737     *   @type string   $dependency  null | 'woocommerce' | 'acf' | ...
     38     *   @type callable|null $verify function( array $config ): array — used by
     39     *                               CLAUTRON_Smoke_Tester. Returns zero or more
     40     *                               check items { status: passed|failed|warning,
     41     *                               label: string, reason?: string }. Return []
     42     *                               when there's nothing meaningful to check for
     43     *                               a given config (e.g. a required field is
     44     *                               empty). Omit entirely (leave null) if this
     45     *                               primitive type has no verification yet — it
     46     *                               will show up as 'unavailable' rather than
     47     *                               being silently skipped.
     48     *   @type string[]|Closure|null $effects  Which of self::EFFECTS the primitive
     49     *                               has, e.g. [ 'writes_database' ]; [] = none.
     50     *                               A Closure( array $config ): string[] when
     51     *                               they depend on config. null (omitted) =
     52     *                               undeclared, which consumers must treat as
     53     *                               "could be anything". An unknown effect name
     54     *                               is reported with _doing_it_wrong() and makes
     55     *                               the primitive undeclared, never "no effect".
     56     *   @type string[] $effects_max Required with a Closure: every effect any
     57     *                               config can produce. The catalog publishes
     58     *                               this worst case; the abilities still answer
     59     *                               per call from the real config.
     60     *   @type bool|Closure|null $reversible  Whether deactivating removes every
     61     *                               trace. Omitted = derived from effects (no
     62     *                               data written, deleted or sent away). Set it
     63     *                               when something outlives deactivation that
     64     *                               effects can't express (a cookie, HSTS).
     65     *   @type array[]  $examples    Example configs. Omitted = the
     66     *                               clautron/primitives-list ability falls back
     67     *                               to configs found in the shipped demos.
    3868     * }
    3969     */
    … …  
    5383            'description' => '',
    5484            'dependency'  => null,
     85            'verify'      => null,
     86            'effects'     => null,
     87            'effects_max' => null,
     88            'reversible'  => null,
     89            'examples'    => [],
    5590        ] );
    5691
    5792        self::$primitives[ $type ]['type'] = $type;
     93        self::check_effects( $type );
     94    }
     95
     96    /**
     97     * A typo in an effect name must not quietly read as "no effect" (and so
     98     * as reversible): report it and treat the primitive as undeclared.
     99     */
     100    private static function check_effects( string $type ): void {
     101        $def     = self::$primitives[ $type ];
     102        $unknown = [];
     103        foreach ( [ 'effects', 'effects_max' ] as $key ) {
     104            if ( is_array( $def[ $key ] ) ) {
     105                $unknown = array_merge( $unknown, array_diff( $def[ $key ], self::EFFECTS ) );
     106            }
     107        }
     108        if ( $unknown ) {
     109            self::$primitives[ $type ]['effects'] = null;
     110            self::doing_it_wrong( "Primitive '{$type}' declares unknown effects: " . implode( ', ', array_unique( $unknown ) ) . '. Allowed: ' . implode( ', ', self::EFFECTS ) . '.' );
     111        } elseif ( $def['effects'] instanceof Closure && ! is_array( $def['effects_max'] ) ) {
     112            self::doing_it_wrong( "Primitive '{$type}' computes its effects from config but declares no effects_max; the catalog will list its effects as unknown." );
     113        }
     114    }
     115
     116    private static function doing_it_wrong( string $message ): void {
     117        if ( function_exists( '_doing_it_wrong' ) ) {
     118            _doing_it_wrong( 'CLAUTRON_Primitive_Registry::register', esc_html( $message ), '1.5.0' );
     119        }
     120    }
     121
     122    /**
     123     * Effect vocabulary, the same keys a capability manifest's `effects` uses
     124     * (Capability Contract v1), so a consumer reads both the same way:
     125     *   writes_database    creates or changes content, meta, options, users,
     126     *                      terms, comments, orders, events or transients
     127     *   writes_files       writes or deletes files on disk
     128     *   deletes_data       removes or trashes site data
     129     *   external_requests  sends data off the site (HTTP, email, AI provider),
     130     *                      including what WordPress itself sends because of
     131     *                      it: pings when a post gets published, the post
     132     *                      author's email when a comment is approved
     133     *   executes_code      outputs markup or script taken from its config
     134     *   changes_schema     changes the content model or API surface (post
     135     *                      types, taxonomies, registered meta, REST routes)
     136     */
     137    public const EFFECTS = [ 'writes_database', 'writes_files', 'deletes_data', 'external_requests', 'executes_code', 'changes_schema' ];
     138
     139    /** Effects that make deactivation an incomplete undo when nothing else is declared. */
     140    private const PERSISTENT_EFFECTS = [ 'writes_database', 'writes_files', 'deletes_data', 'external_requests' ];
     141
     142    /**
     143     * Effects of one node, as EFFECTS => bool, or null when the primitive
     144     * (or the type itself) is unknown or declares none.
     145     */
     146    public static function node_effects( string $type, array $config = [] ): ?array {
     147        $def = self::get( $type );
     148        if ( ! $def || null === $def['effects'] ) {
     149            return null;
     150        }
     151        $list = $def['effects'] instanceof Closure ? (array) ( $def['effects'] )( $config ) : (array) $def['effects'];
     152        if ( array_diff( $list, self::EFFECTS ) ) {
     153            self::doing_it_wrong( "Primitive '{$type}' computed unknown effects: " . implode( ', ', array_diff( $list, self::EFFECTS ) ) . '.' );
     154            return null;
     155        }
     156        return array_combine( self::EFFECTS, array_map( static fn( string $e ): bool => in_array( $e, $list, true ), self::EFFECTS ) );
     157    }
     158
     159    /**
     160     * Every effect the primitive can have with any config: effects_max for a
     161     * config-dependent declaration, the static list otherwise. null when
     162     * undeclared or when a Closure has no effects_max.
     163     *
     164     * @return string[]|null
     165     */
     166    public static function max_effects( string $type ): ?array {
     167        $def = self::get( $type );
     168        if ( ! $def || null === $def['effects'] ) {
     169            return null;
     170        }
     171        if ( $def['effects'] instanceof Closure ) {
     172            return is_array( $def['effects_max'] ) ? array_values( $def['effects_max'] ) : null;
     173        }
     174        return array_values( (array) $def['effects'] );
     175    }
     176
     177    /** Whether deactivating undoes the primitive with every config (false when any config can't). */
     178    private static function always_reversible( array $def, ?array $max ): bool {
     179        if ( null === $max || $def['reversible'] instanceof Closure ) {
     180            return false;
     181        }
     182        if ( null !== $def['reversible'] ) {
     183            return (bool) $def['reversible'];
     184        }
     185        return ! array_intersect( $max, self::PERSISTENT_EFFECTS );
     186    }
     187
     188    /**
     189     * Combined effects of every node in a blueprint (structures, conditions,
     190     * actions), whether deactivating it would undo all of them, and which
     191     * node types declare no effects (consumers should treat those as risky).
     192     *
     193     * @return array{effects: array<string,bool>, reversible: bool, undeclared: string[]}
     194     */
     195    public static function effects_of( array $blueprint ): array {
     196        $effects    = array_fill_keys( self::EFFECTS, false );
     197        $reversible = true;
     198        $undeclared = [];
     199
     200        foreach ( [ 'structures', 'conditions', 'actions' ] as $list ) {
     201            foreach ( (array) ( $blueprint[ $list ] ?? [] ) as $node ) {
     202                $type   = (string) ( $node['type'] ?? '' );
     203                $config = (array) ( $node['config'] ?? [] );
     204                $node_effects = self::node_effects( $type, $config );
     205                if ( null === $node_effects ) {
     206                    $undeclared[] = $type;
     207                    $reversible   = false;
     208                    continue;
     209                }
     210                foreach ( $node_effects as $effect => $has ) {
     211                    $effects[ $effect ] = $effects[ $effect ] || $has;
     212                }
     213                $reversible = $reversible && self::node_reversible( $type, $config, $node_effects );
     214            }
     215        }
     216
     217        return [ 'effects' => $effects, 'reversible' => $reversible, 'undeclared' => array_values( array_unique( $undeclared ) ) ];
     218    }
     219
     220    private static function node_reversible( string $type, array $config, array $effects ): bool {
     221        $declared = self::$primitives[ $type ]['reversible'];
     222        if ( $declared instanceof Closure ) {
     223            return (bool) $declared( $config );
     224        }
     225        if ( null !== $declared ) {
     226            return (bool) $declared;
     227        }
     228        return ! array_filter( array_intersect_key( $effects, array_flip( self::PERSISTENT_EFFECTS ) ) );
    58229    }
    59230
    … …  
    120291
    121292    /**
    122      * Build the capability catalog injected into the AI system prompt.
    123      * Returns a lightweight array — type, category, description, schema.
     293     * Build the capability catalog injected into the AI system prompt and
     294     * returned by the clautron/primitives-list ability.
     295     *
     296     * `safe` false = needs explicit approval before use; `verifiable` = the
     297     * primitive declares a verify check, so a smoke test can confirm it.
     298     * `effects` is the worst case over every config (null when undeclared or
     299     * unknown) and `reversible` is true only when deactivating undoes it with
     300     * every config; `effects_vary` = some config values are milder, so ask
     301     * effects_of() for the real config (the abilities return it per call).
    124302     *
    125303     * @return array[]
    … …  
    128306        $catalog = [];
    129307        foreach ( self::$primitives as $type => $def ) {
     308            $max       = self::max_effects( $type );
    130309            $catalog[] = [
    131                 'type'        => $type,
    132                 'category'    => $def['category'],
    133                 'phase'       => $def['phase'],
    134                 'label'       => $def['label'],
    135                 'description' => $def['description'],
    136                 'schema'      => $def['schema'],
    137                 'dependency'  => $def['dependency'],
     310                'type'         => $type,
     311                'category'     => $def['category'],
     312                'phase'        => $def['phase'],
     313                'label'        => $def['label'],
     314                'description'  => $def['description'],
     315                'schema'       => $def['schema'],
     316                'dependency'   => $def['dependency'],
     317                'safe'         => (bool) $def['safe'],
     318                'verifiable'   => is_callable( $def['verify'] ),
     319                'effects'      => null === $max ? null : array_values( array_intersect( self::EFFECTS, $max ) ),
     320                'effects_vary' => $def['effects'] instanceof Closure || $def['reversible'] instanceof Closure,
     321                'reversible'   => self::always_reversible( $def, $max ),
     322                'examples'     => array_values( (array) $def['examples'] ),
    138323            ];
    139324        }
  • clautron/trunk/readme.txt

    r3691450 r3784837  
    33Tags: capability, automation, security, no-code, ai
    44Requires at least: 7.0
    5 Tested up to: 7.0
    6 Stable tag: 1.2.0
     5Tested up to: 7.1
     6Stable tag: 1.5.0
    77Requires PHP: 8.1
    88License: GPL-2.0-or-later
    … …  
    2121**One secure layer. Thousands of capabilities.**
    2222
    23 Every capability Clautron builds can be activated, deactivated, and rolled back the same predictable way. Before you turn one on, you can see what it reads, what it writes, what it connects to, and how much it changes.
     23Every capability Clautron builds can be activated and deactivated the same predictable way, without touching any data it wrote while it was on. Before you turn one on, you can see what it reads, what it writes, what it connects to, and how much it changes.
    2424
    2525**Key features:**
    … …  
    2828* **Search first, generate second:** Clautron checks what already exists before generating anything new; when it does generate something, you review it before it runs
    2929* **Know what runs on your site:** every capability is built from a declared, inspectable set of primitives
    30 * **Reversible by design:** activate, deactivate, or roll back any capability without digging through code
     30* **Deactivate without data loss:** turn any capability off without digging through code. Nothing it wrote while active is touched, and turning it back on reactivates the same capability instead of starting over
     31* **Machine-readable capability manifest:** each capability can declare its own version, requirements, required permissions, and effects (what it reads, writes, or connects to). External tools like AICOM can inspect and manage capabilities through this same contract, without hardcoded per-capability logic
     32* **Know what a capability does before you turn it on:** every building block declares whether it writes data, deletes data, sends data off the site or outputs code, and whether turning it off fully undoes it
     33* **Security hardening without a security plugin:** stop username discovery, add the missing security headers and hide the WordPress version, each as a small capability you can switch off again
     34* **Ready for AI agents:** every Clautron operation is a WordPress Ability with its own schema, so AI agents and tools like AICOM can find and use them without custom integration code
    3135* **Scheduled jobs and reports:** recurring automations and on-demand data queries, without a separate plugin for each
    3236* **Safe by construction:** a safety policy layer blocks dangerous operations before they run
    … …  
    5155= What are primitives, and what's a capability? =
    5256
    53 Primitives are the small, reusable building blocks Clautron composes things from: custom post types, meta fields, admin columns, scheduled tasks, and more. A capability is what you actually get: a specific piece of functionality (a redirect, an admin column, a WooCommerce rule) built from one or more primitives, that you can activate, deactivate, or roll back as one unit.
     57Primitives are the small, reusable building blocks Clautron composes things from: custom post types, meta fields, admin columns, scheduled tasks, and more. A capability is what you actually get: a specific piece of functionality (a redirect, an admin column, a WooCommerce rule) built from one or more primitives, that you can activate or deactivate as one unit. Some capabilities also carry a manifest (a declared version, requirements, and effects) that lets external tools install, inspect, update, and deactivate them without capability-specific code.
    5458
    5559= Does this plugin store data in the database? =
    5660
    57 Yes. Clautron creates its own database tables (prefixed with `wp_clautron_`) to store blueprints, conversation history, scheduled jobs, workflow state, and execution logs. All data is removed if you uninstall the plugin.
     61Yes. Clautron creates its own database tables (prefixed with `wp_clautron_`) to store blueprints, conversation history, scheduled jobs, workflow state, capability install state, and execution logs. All data is removed if you uninstall the plugin.
    5862
    5963= What permissions are required? =
    6064
    61 Clautron is accessible only to administrators (`manage_options` capability). No frontend functionality or public-facing pages are added to your site.
     65Clautron's own screens are only available to administrators (`manage_options` capability). Clautron adds nothing to the frontend by itself: visitors only see changes from capabilities you activate, such as a consent banner, a redirect or security headers.
    6266
    6367== External Services ==
    … …  
    7579
    7680== Changelog ==
     81
     82= 1.5.0 =
     83* Added harden_user_enumeration: stops anonymous visitors from discovering usernames. Logged-out requests to the /wp/v2/users REST endpoints get a 401 (logged-in users and the block editor keep working), every author archive returns a 404, the users sitemap is removed, oEmbed shows the site instead of the author, and the login form gives the same error for an unknown username and a wrong password.
     84* Added security_headers: sends Referrer-Policy, X-Content-Type-Options, X-Frame-Options and Permissions-Policy, plus Strict-Transport-Security (HSTS) on HTTPS when you opt in. Its check loads your homepage to confirm the headers actually reach visitors, so a page cache or CDN that drops them is reported.
     85* Added hide_wp_version: removes the WordPress, WooCommerce and Elementor generator tags from pages and feeds, and masks the WordPress version in asset URLs while keeping browser caches working. Optionally deletes readme.html, and deletes it again after each WordPress update.
     86* Added a ready-made Security Hardening capability built from these three primitives.
     87* Every Clautron operation (catalog, install, inspect, update, deactivate, blueprints, events) is now a WordPress Ability with its own input schema and behavior annotations, so AI agents and management tools such as AICOM can discover and call them without Clautron-specific code. New: primitive-apply, which applies a single primitive as its own capability, validated and verified.
     88* Every primitive now declares its effects: whether it writes data, writes files, deletes data, sends data off the site, outputs code from its configuration, or changes the content model, and whether turning it off fully undoes it. Installing, applying or ensuring a capability reports these effects, in a dry run too, together with the step that reverses the change.
     89* Fixed ensuring a deactivated custom capability creating a second copy of it: it is now reactivated.
     90* Fixed register_cpt writing the edit_post, read_post and delete_post meta capabilities into the administrator role on every page load.
     91* http_request, fetch_feed, create_post and add_admin_notice are now flagged as needing approval, like the other primitives that can send data off the site, delete data or output code.
     92
     93= 1.4.0 =
     94* Added consent_banner: a category-based cookie-consent banner (necessary/analytics/marketing) with an anonymous REST endpoint to record the visitor's choice, a full audit trail (who consented to what, when, from which source) in the events store, and automatic opt-out when the visitor's browser sends the Global Privacy Control (Sec-GPC) signal.
     95* Added gate_script_by_consent: outputs a script or HTML block only once its required consent category has been granted, reading the same cookie consent_banner writes.
     96* consent_banner can emit Google Consent Mode v2 signals (gtag consent default/update), mapping consent categories to Google's ad_storage/analytics_storage/ad_user_data/ad_personalization signals for GA4 and Ads.
     97* Fixed handle_upload's internal failure closure declaring a standalone `false` return type, valid only since PHP 8.2, which fataled on activation despite the plugin declaring Requires PHP 8.1.
     98
     99= 1.3.0 =
     100* Added a machine-readable capability manifest (version, platform/PHP/WordPress requirements, required permissions, declared effects, reversibility), kept separate from a capability's blueprint and joined only by capability_id. Lets external tools like AICOM install, inspect, update, and deactivate capabilities through one consistent contract instead of capability-specific code.
     101* Added a new `wp_clautron_capabilities` table tracking each capability's installed version and status separately from its blueprint. This enables comparing installed vs. available version and reinstalling a deactivated capability without losing its edit history.
     102* Added a deactivate operation: stops a capability from being re-applied on future requests without touching any data it wrote while active. Reinstalling afterward reactivates the same capability rather than creating a duplicate.
     103* Added an update operation: re-syncs an installed capability to its manifest's current declared version in place.
     104* Primitive verification (used to confirm a compiled capability is correctly wired into WordPress) is no longer a fixed, hardcoded checklist covering a handful of primitive types: each primitive now declares its own check, and any primitive without one is reported explicitly rather than silently skipped.
     105* Fixed the metabox verification check to look up the actual registered meta box ID (it was checking an ID that never matched, so it could never report success).
    77106
    78107= 1.2.0 =
    … …  
    132161== Upgrade Notice ==
    133162
     163= 1.5.0 =
     164Adds security hardening primitives, WordPress Abilities for every operation, and declared effects for every primitive. No breaking changes.
     165
     166= 1.3.0 =
     167Adds a capability manifest, deactivate/update operations, and per-primitive verification. No breaking changes.
     168
    134169= 1.1.0 =
    135170Adds Elementor and WordPress widget primitives. No breaking changes.
  • clautron/trunk/uninstall.php

    r3582869 r3784837  
    1616
    1717delete_option( 'clautron_settings' );
     18delete_option( 'clautron_readme_removed_for' ); // hide_wp_version's remove_readme bookkeeping
    1819delete_transient( 'clautron_active_workflows' );
Note: See TracChangeset for help on using the changeset viewer.