You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Current::load_currents() is hooked to the init and wp actions and populates the cached $site / $customer / $membership properties read by WP_Ultimo()->currents->get_*(). The ?wu-ajax=1 light-ajax pipeline (Light_Ajax::process_light_ajax) dispatches its wu_ajax_* action handlers at plugins_loaded priority 20 and calls die() immediately afterwards, so init never fires and the cache stays null.
Every call site that reads WP_Ultimo()->currents->get_customer() or ->get_membership() from inside a light-ajax-reachable code path therefore sees null even when the user IS logged in as a real customer with the right ownership. That has produced two visible bugs already and an audit found three more dormant ones — see the predecessor PR #1113 for the full list and the local fallbacks that were applied site-by-site.
The right long-term fix is to make Current resilient to being read before init: the first time a get_*() accessor is called against a null cache, run load_currents() on demand, then return the populated value.
Why a separate issue
The local fallbacks in #1113 are intentionally minimal and per-call-site — they were the right shape for an urgent customer-visible regression and a clean audit-derived companion fix. But they're band-aids: every new call site that reads currents->get_* from a wu-ajax path has to remember the same pattern, and a future regression won't surface as a clean error.
A lazy-load of Current::load_currents() would eliminate the bug class and let us revert the per-site fallbacks. The blast radius is non-trivial though — every WP_Ultimo()->currents->get_* caller in the entire codebase changes timing characteristics, including third-party addons that hook wu_current_set_* filters. That deserves its own change with deliberate thought, ideally a beta cycle, and ideally a quick scan of the public addon ecosystem for callers that would observe the new firing order.
Files to inspect
inc/class-current.php — load_currents() and the get_site() / get_customer() / get_membership() accessors. The lazy-load hook would land in the accessors, gated by a $loaded flag so we don't re-run on every read.
inc/class-light-ajax.php — process_light_ajax runs at plugins_loaded:20 and calls die(); this is what guarantees init won't fire.
load_currents() already short-circuits cleanly — it sets $this->customer regardless of whether the cache was hit, so a second call is idempotent. We'd add a $loaded flag to track first-call to avoid the cost of repeated lookups when the user genuinely has no customer record.
Verification
After the lazy-load lands, revert the five site-local fallbacks (Site::is_customer_allowed(), Membership::is_customer_allowed(), Checkout_Form::*_form_fields() x 2, Site_Manager::maybe_validate_add_new_site()).
Re-run the regression flow that surfaced this: log in as a customer, hit the customer-panel template-switching page, click Apply on the same template, confirm the AJAX call returns success (not not_authorized).
Re-run a delete-site form action from the customer panel; confirm no spurious "not allowed" error.
Manual check that any addon hooking wu_current_set_customer / _membership / _site still observes the filter in a sensible order — the filter will now fire later for wu-ajax requests (at first-read instead of at init).
Pattern reference
The fallback pattern used in #1113 mirrors Current::load_currents()'s own heuristic — wu_get_current_customer() for the customer (no Current dependency) and (array) $customer->get_memberships()[0] for the membership. The lazy-load implementation should call the same load_currents() to keep both code paths converged.
aidevops.sh v3.14.72 plugin for OpenCode v1.14.33 with claude-sonnet-4-6 spent 1d 18h on this as a headless worker.
Summary
Current::load_currents()is hooked to theinitandwpactions and populates the cached$site/$customer/$membershipproperties read byWP_Ultimo()->currents->get_*(). The?wu-ajax=1light-ajax pipeline (Light_Ajax::process_light_ajax) dispatches itswu_ajax_*action handlers atplugins_loadedpriority 20 and callsdie()immediately afterwards, soinitnever fires and the cache stays null.Every call site that reads
WP_Ultimo()->currents->get_customer()or->get_membership()from inside a light-ajax-reachable code path therefore sees null even when the user IS logged in as a real customer with the right ownership. That has produced two visible bugs already and an audit found three more dormant ones — see the predecessor PR #1113 for the full list and the local fallbacks that were applied site-by-site.The right long-term fix is to make
Currentresilient to being read beforeinit: the first time aget_*()accessor is called against a null cache, runload_currents()on demand, then return the populated value.Why a separate issue
The local fallbacks in #1113 are intentionally minimal and per-call-site — they were the right shape for an urgent customer-visible regression and a clean audit-derived companion fix. But they're band-aids: every new call site that reads
currents->get_*from a wu-ajax path has to remember the same pattern, and a future regression won't surface as a clean error.A lazy-load of
Current::load_currents()would eliminate the bug class and let us revert the per-site fallbacks. The blast radius is non-trivial though — everyWP_Ultimo()->currents->get_*caller in the entire codebase changes timing characteristics, including third-party addons that hookwu_current_set_*filters. That deserves its own change with deliberate thought, ideally a beta cycle, and ideally a quick scan of the public addon ecosystem for callers that would observe the new firing order.Files to inspect
inc/class-current.php—load_currents()and theget_site()/get_customer()/get_membership()accessors. The lazy-load hook would land in the accessors, gated by a$loadedflag so we don't re-run on every read.inc/class-light-ajax.php—process_light_ajaxruns atplugins_loaded:20and callsdie(); this is what guaranteesinitwon't fire.inc/models/class-site.php—is_customer_allowed()site-local fallback added in fix(template-switch+light-ajax+stripe+settings): four follow-ups from end-to-end UX validation #1113 that the architectural fix should be able to revert.inc/models/class-membership.php— same.inc/models/class-checkout-form.php—membership_change_form_fields()andadd_new_site_form_fields()site-local fallbacks.inc/managers/class-site-manager.php—maybe_validate_add_new_site()site-local fallback.Implementation sketch
load_currents()already short-circuits cleanly — it sets$this->customerregardless of whether the cache was hit, so a second call is idempotent. We'd add a$loadedflag to track first-call to avoid the cost of repeated lookups when the user genuinely has no customer record.Verification
Site::is_customer_allowed(),Membership::is_customer_allowed(),Checkout_Form::*_form_fields()x 2,Site_Manager::maybe_validate_add_new_site()).not_authorized).wu_current_set_customer/_membership/_sitestill observes the filter in a sensible order — the filter will now fire later for wu-ajax requests (at first-read instead of atinit).Pattern reference
The fallback pattern used in #1113 mirrors
Current::load_currents()'s own heuristic —wu_get_current_customer()for the customer (no Current dependency) and(array) $customer->get_memberships()[0]for the membership. The lazy-load implementation should call the sameload_currents()to keep both code paths converged.aidevops.sh v3.14.72 plugin for OpenCode v1.14.33 with claude-sonnet-4-6 spent 1d 18h on this as a headless worker.