Skip to content

12: Conflict resolution - #12

Closed
nikolaystrikhar wants to merge 1 commit into
mainfrom
12-conflict-resolver
Closed

12: Conflict resolution#12
nikolaystrikhar wants to merge 1 commit into
mainfrom
12-conflict-resolver

Conversation

@nikolaystrikhar

@nikolaystrikhar nikolaystrikhar commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

What: conflict resolution becomes the first step of Boot\Scheduler's sequence — plugins_loaded priority 1, ahead of the load pass at 2 — asking Conflict\Gatekeeper::may_resolve() and only then Resolver_Interface::resolve_all(), with Resolver, Gatekeeper and Redirector bound in Provider and the resolver reachable as Loader::resolver().

Usage:

Loader::register( [
    'slug'                       => 'give-recurring',
    'bundled_plugin_file'        => GIVE_PLUGIN_DIR . 'sub-plugins/recurring/give-recurring.php',
    'plugin_loaded_constant'     => 'GIVE_RECURRING_VERSION',
    'standalone_plugin_basename' => 'give-recurring/give-recurring.php',
    'conflict_policy'            => Conflict_Policy::DEACTIVATE, // the default
    'conflict_notice_message'    => static fn() => __( 'Recurring ships with Give now.', 'give' ),
] );

// Or decide per sub-plugin at runtime:
add_filter( 'give/plugin_absorber/conflict_policy', static function ( $policy, $sub_plugin ) {
    return give_standalone_is_newer( $sub_plugin ) ? Conflict_Policy::DEFER : $policy;
}, 10, 2 );

// Or replace what a conflict means outright:
$container->singleton( Resolver_Interface::class, My_Resolver::class );

The standalone is deactivated silently, the merge notice is queued, and the admin lands back on the screen they were on.

Why this way:

Resolution takes a priority of its own, and the boot barrier moves with it. A standalone that survives defines the guard constant as it loads and the load pass has to see that, so the two can't share a priority; wiring_window_has_closed() now measures against the lowest priority in the sequence rather than the load's, which catches a host booting into the gap between them.

The gatekeeper is a separate binding, resolved before the resolver. Who may have a conflict resolved is our invariant; what a conflict means is the host's policy — split that way, a host's Resolver_Interface is never even built on a request that fails a gate.

Both gates, and the capability one covers every policy. plugins_loaded fires on every request, so an ungated resolve turns a visitor's checkout POST into a 302 and exits a WP-CLI run silently; and it runs before auth_redirect(), so an unauthenticated GET of an admin URL would otherwise reach a site-wide deactivation. Gating NOTICE_ONLY too is free, since Notices\Queue::render() won't render or clear for that user anyway.

An unrecognised policy is normalised before the switch, not caught by a default: after it. Conflict_Policy::is_valid() maps anything unknown to NOTICE_ONLY, so a typo like 'defered' produces a sentence rather than a deactivation.

Where to send the user isn't what to do about the conflict. Redirector::after_deactivation() returns a destination or false and the exit stays in the resolver, so every destination is assertable — and it matches on screen basename, since wp_get_referer() prefers the bare _wp_http_referer path that admin_url() comparisons miss behind a proxy or in the network admin.

@nikolaystrikhar nikolaystrikhar changed the title 12: Conflict resolver 12: Conflict resolver (merge after #11 — last in the chain) Aug 11, 2026
@nikolaystrikhar nikolaystrikhar changed the title 12: Conflict resolver (merge after #11 — last in the chain) 12: Conflict resolution Aug 11, 2026
Conflict\Resolver takes its four collaborators as required constructor
arguments -- the checker, the deactivator, the notice queue and the redirector --
because the container is now mandatory and nothing has to be constructible
without one. The nullable peers and the accessors that fell back to a static
are gone with the class they fell back to.

Conflict\Gatekeeper owns who may resolve: an interactive admin GET, the
activate_plugins capability, and a hook prefix. plugins_loaded runs on every
request and fires before auth_redirect(), so an unauthenticated GET of an admin
URL reaches this code, and the capability gate covers every policy rather than
only the destructive one -- the other branches queue a notice the same user could
not render anyway. The priority-1 step asks the gatekeeper before it resolves
Resolver_Interface at all, so a host binding its own resolver cannot drop either
gate by omission.

Conflict\Destination becomes Conflict\Redirector. It decides where to send the
user and never goes there; wp_safe_redirect() and the exit after it stay in the
resolver, so the policy action and the admin-URL knowledge change for separate
reasons. A class that returns a URL from a filter still earns the agent noun.

The boot barrier now measures from the lowest priority in the sequence rather
than from the load priority, or a host booting between conflict resolution and
the load would be told nothing while half its wiring silently failed.
@nikolaystrikhar
nikolaystrikhar force-pushed the 11-loader-load-path branch 2 times, most recently from b28c21e to c7760ef Compare August 12, 2026 14:01
Base automatically changed from 11-loader-load-path to main August 12, 2026 14:59
Comment thread src/Boot/Scheduler.php
Comment on lines 37 to +49
private const LOAD_PRIORITY = 2;

/**
* plugins_loaded priority conflict resolution runs at, ahead of the load pass.
*
* A standalone that survives the conflict defines the guard constant as it loads, and the load
* pass has to see that, so resolution cannot share a priority with it.
*
* @since 1.0.0
*
* @var int
*/
private const RESOLVE_PRIORITY = 1;

@d4mation d4mation Aug 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something to maybe note/consider:

1 and 2 are based on LearnDash/MemberDash loading its Container at plugins_loaded:0, right? For instance, the implementation in Shop Kit was able to handle this logic a bit later due to it not having as strong conflict resolution (it didn't need it) and its Container was set at plugins_loaded:1 instead of plugins_loaded:0.

Our main constraints are:

  1. LOAD_PRIORITY needs to be before plugins_loaded:10
    • Mainly an assumption that the earliest an absorbed plugin would be hooking is to plugins_loaded:10 as hooking before 10 should only be done in special cases like what we're doing here
  2. RESOLVE_PRIORITY needs to be before LOAD_PRIORITY
  3. LOAD_PRIORITY and RESOLVE_PRIORITY both need to be after the Host Plugin has set up their Container
    • Likely on plugins_loaded somewhere, but hopefully before plugins_loaded:10

I do think the earlier the better for this type of thing, but maybe we could scoot these priorities a little later to more easily allow other code to run before our hooks if needed. With RESOLVE_PRIORITY = 1, if LearnDash had to hook in before us, for instance, almost the only way to safely ensure this would be to simply run the code before ever running Plugin Absorber without utilizing hooks at all.

The main downside I see currently is that we are basically forcing Plugin Absorber to be set up at plugins_loaded:0 because if it is done any later, it won't run properly. While there are checks for that in the code added in #11 and there's the inline loader to try to account for it, it could still potentially be a pain point.

@nikolaystrikhar

Copy link
Copy Markdown
Contributor Author

Superseded by #19 and #20, which split this in two along the line between the classes that only answer a question and the one that acts on the answers:

Both are rebased onto main, so they carry the Absorber / Loader rename from #18.

@d4mation — your note on the priorities is in #20. Taking your three constraints: resolution now runs at plugins_loaded priority 5 and the load at 6. The argument that decided it is the one you led with — the too-late barrier measures against the first step, so the resolve priority is what a host has to beat, and at 1 the only slot left was 0. Since LearnDash and MemberDash both wire Harbor's set_container() at priority 1, a host copying the habit it already has was landing exactly on the barrier and silently taking the inline fallback. At 5 there are five slots below us, and SchedulerTest now asserts that booting at 0, 1 and 4 wires rather than runs inline — those cases fail if anyone moves it back.

One thing that argues against going later still, which is worth recording since it is invisible from the host side: every priority below the load is a band of the bundled plugin's own plugins_loaded callbacks that never fire. A standalone copy is included by wp-settings.php before the action is dispatched at all, so it keeps every callback it registers; a bundled copy required from a callback only keeps the ones above the priority that required it. Your assumption that an absorbed plugin hooks at 10 is probably right, but where it isn't the failure is silent and the host cannot fix it — so 6 rather than 9, on the grounds that the number should rise only as far as it has to.

@nikolaystrikhar

Copy link
Copy Markdown
Contributor Author

Closing in favour of #19 and #20.

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