Skip to content

Add critical security-update task with UI lockdown and admin alerts - #769

Open
tacoverdo wants to merge 8 commits into
developfrom
taco/security-update-task
Open

Add critical security-update task with UI lockdown and admin alerts#769
tacoverdo wants to merge 8 commits into
developfrom
taco/security-update-task

Conversation

@tacoverdo

@tacoverdo tacoverdo commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

What

When a WordPress core security release is available, put site owners on highest alert:

  • A new security-update task (priority 0, 2 points, not dismissable, not snoozable) becomes the only recommendation shown to users who can install it — including under "Show all recommendations" — until the update is installed.
  • The task carries a one-click, branch-pinned update button ("Update to WordPress 6.9.7 now"): a form POSTing the exact offered version + locale to core's own update-core.php?action=do-core-upgrade handler. This matters because the wp-admin Updates page never shows same-branch patches (get_core_updates() skips autoupdate offers) and would push a branch-behind user toward the next major instead. find_core_update() accepts any offer in the transient, so core's native upgrade flow (nonce, capability, maintenance mode, FS credentials) runs pinned to the branch patch. A "Go to the Updates page" link remains as fallback.
  • All administrators (update_core users) receive a direct wp_mail alert, once per offered version. No email addresses leave the site, and the alert path makes zero outbound HTTP requests (covered by a regression test).
  • The get-stats payload gains a security_updates block (pending, installed_version, offered_version, last_alerted_version) so progressplanner.com can email the registered subscriber via the feed-driven approach (see below).

The alert email

Sent as plain text, one individually addressed wp_mail per recipient, translatable via the progress-planner textdomain. For an onboarded site (example: 6.9.1 offered 6.9.7):

Subject: [Example Site] Critical: WordPress 6.9.7 security update available

A WordPress security release is available: version 6.9.7 (your site runs 6.9.1).

Security issues in WordPress are typically exploited within hours of a release, so please update as soon as possible:

https://example.org/wp-admin/admin.php?page=progress-planner

Your Progress Planner dashboard has a one-click button to install exactly this update; the task will be marked complete once your site is updated.

If you have a backup solution, make a fresh backup before updating — but do not postpone the update if you have none. Learn more about backups: https://wordpress.org/documentation/article/wordpress-backups/

If your site has automatic updates enabled, it may install this update by itself — in that case, please verify that the update has been applied.

This alert was sent by the Progress Planner plugin.

Sites that have not onboarded get the same email without the dashboard paragraph, with the link pointing to wp-admin/update-core.php instead (their PP dashboard would show the welcome screen, not the task).

Detection

Every patch release on the installed branch (e.g. 6.8.1 → 6.8.2) is treated as a security release. Cross-branch jumps (6.7.2 → 6.8.0) and alpha/beta/RC builds never trigger. Branch-behind sites are covered because WordPress serves a same-branch point-release offer whenever security fixes ship — note that once a newer major exists, that offer arrives with response autoupdate (only the newest release is labeled upgrade), so detection accepts both response types. Verified against the live version-check API for a 6.9.1 site (upgrade 7.0.4 / autoupdate 7.0.4 / autoupdate 6.9.7 → detects 6.9.7).

Detection hooks set_site_transient_update_core, so it fires from front-end cron — no admin visit needed — with an admin_init fallback.

Lifecycle

  • Task IDs are versioned (security-update-6-8-2): each release is a fresh task with fresh points, even after an older one was completed.
  • Superseded or withdrawn offers are cleaned up silently (no bogus celebration).
  • Installing the update manually completes the task with the normal celebration; auto-updates complete it silently via automatic_updates_complete.

Deliberately not locked down

  • Users without update_core (they can't act on it) keep their normal task list.
  • The user's own to-do list (user provider).
  • Pending-celebration and other non-publish statuses, so celebrations still fire during lockdown.
  • The SaaS-facing /progress-planner/v1/tasks endpoint.

Also in this PR

  • rest_prepare_recommendation() hardened against an undefined prpl_url meta index (exposed by the new tests when meta isn't registered).
  • on_automatic_updates_complete() generalized to handle both update-core and security-update tasks.

SaaS-side work (separate, on progressplanner.com): feed-driven subscriber email

No new endpoint. Instead, progressplanner.com watches the wordpress.org releases feed; when a patch release ships for branch X.Y, it polls connected sites' existing get-stats endpoint and emails the subscriber of any site whose security_updates.installed_version is on branch X.Y below the new patch. Notes:

  • Decide from installed_version + the feed, not the site's pending flag — a freshly polled site's own update check may not have run yet. installed_version reads the running version and is always accurate.
  • Dedupe per (site, offered version) server-side.
  • Queue/spread the release-day polling burst.

An earlier revision of this PR pushed a security-update-alert ping to the SaaS; it was removed (see commit history) because a push-triggers-email endpoint is an abuse surface the feed-driven design avoids entirely.

Testing

  • 33 new PHPUnit tests (detection matrix, alert throttling/recipients/no-outbound-HTTP, provider lifecycle incl. superseded/withdrawn offers, lockdown on both REST chokepoints, System_Status). Full suite: 432 tests / 1269 assertions green.
  • PHPStan level 10, WPCS, and PHP lint all clean.
  • Not yet covered: a Playwright spec (would fake the offer via pre_site_transient_update_core and assert the single-card lockdown) and manual verification on a live site.

🤖 Generated with Claude Code

When a WordPress core security release is available (any patch release
on the installed branch, e.g. 6.8.1 -> 6.8.2), the plugin now:

- Publishes a top-priority, non-dismissable, non-snoozable
  "security-update" task that becomes the ONLY recommendation shown to
  users who can install it, until the update is installed. Editors,
  user to-dos and pending celebrations are unaffected.
- Emails all administrators (update_core users) directly via wp_mail,
  once per offered version.
- Pings the new progress-planner-saas/v1/security-update-alert endpoint
  (license key + remote nonce, same pattern as change-site-url) so the
  SaaS can email the registered subscriber, and exposes a
  security_updates block in the get-stats payload.

Detection runs on the set_site_transient_update_core hook so it works
from front-end cron without an admin visit. Task IDs are versioned per
release; superseded or withdrawn offers are cleaned up silently, and
installing the update (manually or via auto-update) completes the task.

Also hardens rest_prepare_recommendation() against an undefined
prpl_url meta index when meta is not registered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Test on Playground
Test this pull request on the Playground
or download the zip

@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

✅ Code Coverage Report

Metric Value
Total Coverage 35.51% 📉
Base Coverage 31.62%
Difference 📈 3.89%

⚠️ Coverage below recommended 40% threshold

🎉 Great job maintaining/improving code coverage!

📊 File-level Coverage Changes (14 files)

🆕 New Files

Class Coverage Lines
🟢 Progress_Planner\Suggested_Tasks\Providers\Security_Update 91.36% 74/81
🟢 Progress_Planner\Utils\Security_Update_Monitor 91.80% 112/122

📈 Coverage Improved

Class Before After Change
Progress_Planner\Activities\Suggested_Task 50.00% 88.89% +38.89%
Progress_Planner\Suggested_Tasks\Task 20.00% 56.67% +36.67%
Progress_Planner\Suggested_Tasks 9.60% 44.01% +34.41%
Progress_Planner\Suggested_Tasks\Providers\Tasks 36.59% 67.07% +30.48%
Progress_Planner\Badges 67.21% 95.08% +27.87%
Progress_Planner\Suggested_Tasks\Providers\Blog_Description 33.33% 54.17% +20.84%
Progress_Planner\Suggested_Tasks\Providers\User 4.55% 18.18% +13.63%
Progress_Planner\Suggested_Tasks\Tasks_Manager 62.83% 70.18% +7.35%
Progress_Planner\Badges\Monthly 72.17% 73.04% +0.87%
Progress_Planner\Utils\System_Status 91.95% 92.71% +0.76%
Progress_Planner\Base 45.40% 45.73% +0.33%
Progress_Planner\Suggested_Tasks_DB 90.11% 90.22% +0.11%
ℹ️ About this report
  • All tests run in a single job with Xdebug coverage
  • Security tests excluded from coverage to prevent output issues
  • Coverage calculated from line coverage percentages

@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

🔍 WordPress Plugin Check Report

❌ Status: Failed

📊 Report

🎯 Total Issues ❌ Errors ⚠️ Warnings
12 1 11

❌ Errors (1)

📁 readme.txt (1 error)
📍 Line 🔖 Check 💬 Message
0 outdated_tested_upto_header Tested up to: 6.9 < 7.1. The "Tested up to" value in your plugin is not set to the current version of WordPress. This means your plugin will not show up in searches, as we require plugins to be compatible and documented as tested up to the most recent version of WordPress.

⚠️ Warnings (11)

📁 classes/utils/class-security-update-monitor.php (1 warning)
📍 Line 🔖 Check 💬 Message
0 update_modification_detected Plugin Updater detected. Detected code which may be altering WordPress update routines. Detected: _site_transient_update_core
📁 classes/suggested-tasks/providers/class-content-review.php (4 warnings)
📍 Line 🔖 Check 💬 Message
232 WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in Using exclusionary parameters, like post__not_in, in calls to get_posts() should be done with caution, see https://docs.wpvip.com/databases/optimize-queries/using-post__not_in/ for more information.
377 WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in Using exclusionary parameters, like post__not_in, in calls to get_posts() should be done with caution, see https://docs.wpvip.com/databases/optimize-queries/using-post__not_in/ for more information.
381 WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in Using exclusionary parameters, like post__not_in, in calls to get_posts() should be done with caution, see https://docs.wpvip.com/databases/optimize-queries/using-post__not_in/ for more information.
388 WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in Using exclusionary parameters, like post__not_in, in calls to get_posts() should be done with caution, see https://docs.wpvip.com/databases/optimize-queries/using-post__not_in/ for more information.
📁 classes/suggested-tasks/data-collector/class-unpublished-content.php (1 warning)
📍 Line 🔖 Check 💬 Message
103 WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in Using exclusionary parameters, like post__not_in, in calls to get_posts() should be done with caution, see https://docs.wpvip.com/databases/optimize-queries/using-post__not_in/ for more information.
📁 classes/suggested-tasks/data-collector/class-yoast-orphaned-content.php (1 warning)
📍 Line 🔖 Check 💬 Message
111 PluginCheck.Security.DirectDB.UnescapedDBParameter Unescaped parameter $query used in $wpdb->get_row()\n$query assigned unsafely at line 98.
📁 classes/suggested-tasks/data-collector/class-terms-without-description.php (1 warning)
📍 Line 🔖 Check 💬 Message
108 PluginCheck.Security.DirectDB.UnescapedDBParameter Unescaped parameter $query used in $wpdb->get_results()\n$query assigned unsafely at line 106.
📁 classes/suggested-tasks/data-collector/class-terms-without-posts.php (1 warning)
📍 Line 🔖 Check 💬 Message
120 PluginCheck.Security.DirectDB.UnescapedDBParameter Unescaped parameter $query used in $wpdb->get_results()\n$query assigned unsafely at line 118.
📁 classes/activities/class-query.php (2 warnings)
📍 Line 🔖 Check 💬 Message
71 PluginCheck.Security.DirectDB.UnescapedDBParameter Unescaped parameter $table_name used in $wpdb->query()\n$table_name assigned unsafely at line 58.
163 PluginCheck.Security.DirectDB.UnescapedDBParameter Unescaped parameter $where_args used in $wpdb->get_results()\n$where_args assigned unsafely at line 153.

🤖 Generated by WordPress Plugin Check Action • Learn more about Plugin Check

tacoverdo and others added 2 commits August 14, 2026 11:47
progressplanner.com will watch the wordpress.org releases feed and read
each site's installed version from the existing get-stats payload
(security_updates.installed_version), instead of sites pushing to a new
endpoint. This removes the push endpoint's abuse surface entirely.

A regression test now asserts the alert path makes no outbound HTTP
requests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On multisite only super admins can update core, so the expected
recipient list must be built from get_super_admins() there — matching
what Security_Update_Monitor::get_recipients() correctly does.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tacoverdo and others added 3 commits August 14, 2026 12:12
The version-check API only labels the newest release "upgrade"; a
branch-behind site (e.g. 6.9.1 when 7.0.x is current) receives its
same-branch security patch (6.9.7) with response "autoupdate". The
response filter only accepted "upgrade", so exactly the most at-risk
sites — those on older branches — never got the task or the alert.

Accept both "upgrade" and "autoupdate" offers; cross-branch entries are
still rejected by the same-major.minor rule, dev builds by the
stability check. Verified against the live API shape for 6.9.1
(upgrade 7.0.4 / autoupdate 7.0.4 / autoupdate 6.9.7 -> detects 6.9.7).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The wp-admin Updates page only lists the latest release —
get_core_updates() skips "autoupdate" offers — so a branch-behind site
(6.9.1 with 7.0.x current) is only ever offered the next major there.

The task actions now include a form that POSTs the exact branch version
and locale to core's own update-core.php?action=do-core-upgrade
handler: find_core_update() matches any offer in the transient
(including autoupdate ones), so the user gets core's native upgrade
flow pinned to e.g. 6.9.7, with the nonce, capability check,
maintenance mode and filesystem-credentials handling all handled by
core. The "Go to the Updates page" link remains as fallback.

Adds Security_Update_Monitor::get_pending_security_update_offer() to
expose the matched offer object (version + locale).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Light-touch advice phrased not to license delay: "make a fresh backup
first — but do not postpone the update if you have none." The one-click
button routes around wp-admin's Updates page, which is where WordPress
normally shows its backup notice, so the task and email carry it
instead. The email links to the wordpress.org backups documentation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The dashboard carries the one-click branch-pinned update button; the
wp-admin Updates page only offers the latest major, so it remains the
target only for sites that have not onboarded yet (their dashboard
would show the welcome screen instead of the task).

Also restructures the email body into separate translatable paragraphs
and rewords the auto-update note so it fits both link targets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ilicfilip

Copy link
Copy Markdown
Collaborator

Solid, well-researched PR. The core insight — that get_core_updates() skips autoupdate offers, so branch-behind sites never see their same-branch security fix on the Updates page — is correct and genuinely valuable.

I verified the detection claim against the live version-check API. A 6.9.1 site, even with locale=nl_NL or de_DE, gets:

upgrade    7.1     locale=nl_NL
upgrade    7.1     locale=en_US
autoupdate 7.1     locale=en_US
autoupdate 7.0.4   locale=en_US
autoupdate 6.9.7   locale=en_US

So accepting both upgrade and autoupdate, and reading the locale from the matched offer rather than the site locale, is right. The do-core-upgrade contract (upgrade=1 + upgrade-core nonce + version + locale) matches wp-admin/update-core.php:1152-1176, and find_core_update() does match any offer in the transient, so core handles the capability check, nonce, maintenance mode and FS credentials as described.

Two things I'd consider blockers.


🔴 1. on_automatic_updates_complete() trashes arbitrary tasks and credits bogus points

classes/class-suggested-tasks.php calls get_suggested_tasks_db()->get(), not get_tasks_by(). Only get_tasks_by() translates provider_id into a tax_query (class-suggested-tasks-db.php:346-358):

case 'provider':
case 'provider_id':
    $args['tax_query'][] = [
        'taxonomy' => 'prpl_recommendations_provider',
        'field'    => 'slug',
        'terms'    => (array) $value,
    ];

get() is a thin get_posts() wrapper, and there's no query_vars registration for provider_id, so the key is silently dropped.

This pre-exists on the update-core line, but the PR amplifies it. The new 'security-update' => [] entry has no date_query, so its query is just post_type=prpl_recommendations, post_status=publish, numberposts=1, orderby=menu_order ASC. It matches the first published recommendation of any provider, of any age — trashes it and calls insert_activity() on it, awarding points for a task the user never did.

It also fires more often than "core was auto-updated": automatic_updates_complete runs after plugin/theme-only auto-update runs too (wp-admin/includes/class-wp-automatic-updater.php:770-783), so this triggers on sites where no core update ever happened.

Fix is a one-liner — use get_tasks_by(). Worth noting the test can't catch this as written: test_automatic_update_completes_security_task() asserts the DB holds exactly one published task before calling, so the broken query returns the right row by accident. Adding a second published task from another provider (the existing add_other_task() helper already does this) would make it fail.

🔴 2. The lockdown can get stuck on, with no manual escape

is_security_lockdown_active() goes through get(), which caches on an md5 of the args in GET_TASKS_CACHE_GROUP. That group is flushed only in delete_recommendation() (class-suggested-tasks-db.php:267) — not in add() or update_recommendation().

Completion trashes the task via update_recommendation( $id, [ 'post_status' => 'trash' ] ), which doesn't flush, so the cached true survives. The asymmetry biases toward the bad direction: if ( $results ) is falsy for [], so the inactive state is never served from cache while the active state is. With a persistent object cache (Redis/Memcached) this can outlive the request.

That matters more than usual here because is_dismissable = false and is_snoozable = false mean the base get_task_actions() emits neither a "Mark as complete" nor a snooze button (class-tasks.php:702, :710). A stuck lockdown leaves the user with a recommendations UI showing one un-actionable task and nothing else. Either flush on trash, or have is_security_lockdown_active() bypass the cache.

🟡 get_effective_installed_version() is dead code in production — and the completion tests depend on it

wp_version_check() always sets version_checked to the running version (wp-includes/update.php:313) and invalidates the transient outright when the two differ (:44):

if ( is_object( $current ) && wp_get_wp_version() !== $current->version_checked ) {
    $current = false;
}

So version_checked > installed can't happen in production, and the transient branch never executes. That's harmless on its own — after a real update wp_get_wp_version() is already correct on the next request — but the provider tests reach completion only through that branch: complete_task() sets version_checked to the offered version without changing the real one. So the completion/celebration path isn't actually being exercised against production behavior. Worth flagging given "not yet covered: manual verification on a live site."

🟡 Minor

  • get_recipients() uses get_users( [ 'capability' => 'update_core' ] ) with no number or fields limit, loading full WP_User objects when only user_email is needed — and it can run during a front-end cron request via the set_site_transient_update_core hook. 'fields' => [ 'user_email' ] would be much cheaper.
  • The alert lock uses site options (add_option/update_option/delete_option) while ALERTED_VERSION_OPTION uses network options. Also, the stale-lock path (>30s) updates the lock and proceeds regardless, so two concurrent requests can both send.
  • $priority = 0 becomes menu_order = 0, because add() uses isset( $data['priority'] ) which is true for 0 — so it ties with unordered tasks rather than strictly sorting first. Moot while the lockdown is active; 1 would match existing convention (lowest currently in use).
  • views/page-widgets/suggested-tasks.php adds a third independent capability check (current_user_can( 'update_core' )) alongside the two chokepoints, which can drift if Security_Update::CAPABILITY ever changes.

✅ Verified sound

  • Versioned task IDs do give fresh points per release — task_has_activity() keys on the exact task ID, so the zeroing at class-suggested-tasks.php:567 doesn't carry over from a previous release.
  • No celebration regression after install: evaluate_task()'s strpos( $task->post_name, $this->get_task_id() ) still matches, because once the offer clears, get_task_id() returns the bare security-update, which is a prefix of security-update-6-9-7. The superseded-offer case correctly fails that check and is cleaned up silently by get_tasks_to_inject().
  • Both chokepoints genuinely cover both render paths — the localized payload from class-enqueue.php:233/241/250, and the Backbone wp.api.collections.Prpl_recommendations fetches. I couldn't find a third path that renders the list.
  • Request arg handling is right: status always arrives as an array (core's get_collection_params() default), provider is always a comma-string, and the (array) cast covers suggested-task.js:542 sending a scalar 'publish'.
  • The ! empty( $response->data['meta']['prpl_url'] ) fix is real — prpl_url is registered without a default, so the index can genuinely be absent.
  • Dropping the push endpoint in favour of the feed-driven design is the right call.

Review assisted by Claude Code.

…elity

Blockers:
- on_automatic_updates_complete() used DB::get(), which drops
  provider_id (only get_tasks_by() builds the tax query), so the
  security-update entry matched the first published task of any
  provider, trashed it and credited points. Switched to get_tasks_by()
  and gated the security task on $update_results['core'] — the hook
  also fires after plugin/theme-only runs. Tests now seed a second
  provider's task and a plugin-only run.
- DB::get() results were only invalidated in delete_recommendation();
  trashing via update_recommendation() left a cached "active" lockdown
  (and the asymmetric `if ( $results )` never caches the inactive
  state). add() and update_recommendation() now flush the group, and
  the wp_cache_flush() workarounds in tests are gone.

Test fidelity:
- Replaced the dead version_checked branch (wp_version_check() keeps
  version_checked equal to the running version) with a single
  get_installed_version() behind a progress_planner_installed_wp_version
  filter, so completion/celebration tests exercise the production
  comparison with only the version source swapped. Added an explicit
  completion test.

Minor:
- get_recipients() fetches 'fields' => 'user_email' only.
- Alert lock uses network options like the throttle; a stale lock is
  cleared and the request bails instead of two requests both sending.
- The widget view gates on the provider's capability_required() instead
  of a literal capability.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@tacoverdo

Copy link
Copy Markdown
Contributor Author

Addressed in 76a6528 — responding to #769 (comment) item by item.

🔴 1. on_automatic_updates_complete() / get() vs get_tasks_by() — Confirmed: get() hands args straight to get_posts(), so provider_id was dropped. Switched to get_tasks_by(). Also now takes the hook's $update_results and only completes the security task when $update_results['core'] is set, since the hook fires after plugin/theme-only runs too (the update-core entry keeps its pre-existing semantics). Tests: the existing test now seeds a second provider's task and asserts it survives; added a plugin-only-run test and a no-security-task test (the latter is the one that fails on the old query regardless of ordering).

🔴 2. Stuck lockdown / cache — Confirmed, including the if ( $results ) asymmetry. Fixed at the source rather than special-casing is_security_lockdown_active(): add() and update_recommendation() now flush GET_TASKS_CACHE_GROUP the same way delete_recommendation() already did. That also removed the wp_cache_flush() calls I'd needed in my own tests — which were a symptom of this. Tests: lockdown lifts after trashing with no manual flush; add()/update_recommendation() invalidation tests in the DB suite.

🟡 get_effective_installed_version() dead in production — Confirmed against wp_version_check(). Replaced with a single get_installed_version() behind a progress_planner_installed_wp_version filter (debug/testing seam). Provider tests now complete via that filter + clearing the offer, so completion/celebration runs through the production version_compare path with only the version source swapped. Added an explicit completion test (the trait's loop-based assertion passes silently when nothing completes).

🟡 Minorget_recipients() uses 'fields' => 'user_email'. Lock moved to network options like the throttle; on a stale lock it now clears and bails rather than taking over and proceeding (test added: first call sends nothing and clears the lock, next call sends). The widget view gates on get_task_provider( 'security-update' )->capability_required() instead of a literal cap.

Priority — keeping 0. Two existing providers already sit at 1, so 1 would tie with them; 0 ties only with tasks carrying no priority at all (menu_order default), and every provider task sets one — and user tasks are excluded from this list. Moot under lockdown either way; happy to change if you feel strongly.

Full suite green in single-site and multisite modes (445 tests), PHPStan level 10, WPCS.

@ilicfilip

Copy link
Copy Markdown
Collaborator

Verified all of it against 76a65281 — both blockers are properly fixed, and in both cases you went for the better fix rather than the one I suggested.

🔴 1get_tasks_by() is right, and gating on $update_results['core'] is a better call than what I flagged; I only noted the hook fires for plugin/theme runs, you actually closed it. Keeping update-core's pre-existing semantics separate is right too. The new no-security-task test is the one that would have caught the original bug independently of row ordering — good.

🔴 2 — Fixing invalidation in add()/update_recommendation() rather than special-casing is_security_lockdown_active() is the correct call; my suggestion would have left the same trap for the next caller. Flush placement is right in both (after the writes, before return, and add()'s sits outside the finally so it can't mask the lock release).

🟡 get_installed_version() filter — good, this is what I was hoping for. Tests now exercise the production version_compare path with only the version source swapped, and the explicit completion test addresses the silent-pass problem in the trait's loop.

🟡 Minor — all fine. Clearing and bailing on a stale lock is better than taking it over. Gating the widget view on capability_required() removes the drift risk.

Priority 0 — you're right, I had it backwards: 1 ties with the two existing providers, 0 only ties with tasks that set no priority, and every provider task sets one. Keep 0.

LGTM. One note for the future, not a merge concern:

wp_cache_flush_group() and wp_cache_supports() are both pluggable, so a persistent object-cache drop-in replaces them entirely. A drop-in that doesn't implement flush_group() would silently no-op and reintroduce the stuck lockdown — on exactly the sites running a persistent cache. Every maintained drop-in supports it and our 6.7 minimum means core is never the problem, so this is a tail risk only. Noting it here so it's the first thing to check if a stuck-lockdown report ever comes in from a site with an object cache.


Review assisted by Claude Code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants