diff --git a/tests/README.md b/tests/README.md
index 6e332a5..dee0b20 100644
--- a/tests/README.md
+++ b/tests/README.md
@@ -536,3 +536,182 @@ sequenceDiagram
Q-->>WP: draws it as notice-error
Q->>Q: clears the queue
```
+
+#### `Scenario/ConflictTest.php` — a standalone copy is still installed
+
+Each of these puts a real basename into the real `active_plugins` option, so
+core's own `deactivate_plugins()` is what turns it off and the real option is
+what says whether it worked. Priority 5 is the step under test, and which branch
+it takes is the sub-plugin's `conflict_policy`:
+
+```mermaid
+flowchart TD
+ A[plugins_loaded priority 5] --> B{request may resolve?}
+ B -- "not an admin GET, or carries an action" --> Z[return]
+ B -- yes --> C{standalone active?}
+ C -- no --> Z
+ C -- yes --> D{user may resolve?}
+ D -- "cannot activate_plugins" --> Z
+ D -- yes --> E{conflict_policy}
+ E -- DEFER --> Z
+ E -- NOTICE_ONLY --> F[queue a notice]
+ E -- "DEACTIVATE (default)" --> G[deactivate, queue, redirect, exit]
+```
+
+The gates are asked in that order on purpose: the detector reports and changes
+nothing, so the cheap question goes in front of `current_user_can()`, which
+resolves and caches the current user for the rest of the request.
+
+**DEACTIVATE deactivates, notifies and redirects.** The default policy, against
+core's own `deactivate_plugins()` rather than a stub of it. The standalone
+leaves `active_plugins`, a merge notice is queued, and the user is sent back to
+re-render what they asked for now that the standalone's code is out of memory.
+The destination is asserted, not merely that a redirect happened.
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant WP as WordPress
+ participant R as Conflict Resolver
+ participant D as Plugin Deactivator
+ participant Q as Notice queue
+ participant Rd as Redirector
+
+ Note over WP: active_plugins holds the standalone
+ WP->>R: plugins_loaded priority 5
+ R->>D: deactivate( standalone ) — silent, network-aware
+ R->>Q: queue_merge_notice()
+ R->>Rd: after_deactivation( request URI )
+ Rd-->>R: admin_url( 'plugins.php' )
+ R->>WP: wp_safe_redirect() then exit
+ Note over WP: the load pass at priority 6 never runs
+```
+
+**The merge notice renders on the next admin screen, and clears.** All the way
+to the screen. This notice is raised exactly once and never re-queued, so the
+admin page load after the deactivation has to draw it — and consume it, or the
+owner reads the same deactivation report for ever. It is a warning, not an
+error: the library has already handled it.
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant WP as WordPress
+ participant R as Conflict Resolver
+ participant Q as Notice queue
+
+ WP->>R: request one — resolves and redirects
+ R->>Q: queue_merge_notice()
+ Note over WP,Q: request two, the screen the user landed on
+ WP->>Q: all_admin_notices
+ Q-->>WP: draws it as notice-warning
+ Q->>Q: clears the queue
+```
+
+**The request after a deactivation does not loop.** The failure mode a merge
+notice queued on every request would produce: a redirect loop, or a screen
+reporting the same deactivation for ever. Nothing is re-registered between the
+two requests — a duplicate slug throws — because this is the next page view, not
+a second bootstrap. The second request must *not* halt, and the helper fails the
+test if it does.
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant WP as WordPress
+ participant R as Conflict Resolver
+ participant L as Loader
+
+ WP->>R: request one — deactivates, queues, redirects
+ Note over WP: the queue is emptied, so a second notice would be visible
+ WP->>R: request two
+ R->>R: no standalone active — nothing to resolve
+ WP->>L: plugins_loaded priority 6
+ L->>L: with the standalone gone, the bundled copy takes over
+```
+
+**DEFER leaves the standalone active and loads nothing.** The policy hands the
+request to the standalone. WordPress includes an active plugin from
+`wp-settings.php` long before `plugins_loaded`, so by the time the resolver runs
+the standalone has already defined the guard constant — which is what stands the
+bundled copy down. Defining it up front is what makes this the scenario the
+policy describes, rather than a resolver that merely declined to act.
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant WP as WordPress
+ participant R as Conflict Resolver
+ participant L as Loader
+
+ Note over WP: the standalone loaded from wp-settings.php and defined the guard
+ WP->>R: plugins_loaded priority 5
+ R->>R: policy is DEFER — no-op
+ WP->>L: plugins_loaded priority 6
+ L->>L: the guard is defined — stand down
+ Note right of L: standalone still active, nothing queued
+```
+
+**NOTICE_ONLY notifies without deactivating.** A policy that only talks must not
+end the request, which is what the non-halting helper asserts.
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant WP as WordPress
+ participant R as Conflict Resolver
+ participant Q as Notice queue
+
+ WP->>R: plugins_loaded priority 5
+ R->>Q: queue_conflict_notice() — the host's own sentence
+ R-->>WP: returns; no deactivation, no redirect
+ Note over WP: the standalone is still in active_plugins
+```
+
+**A user who cannot activate plugins resolves nothing.** The gate that survives
+every policy and every rebinding: whoever cannot activate a plugin must not be
+able to deactivate one by loading an admin page. Nothing is consumed by
+refusing — the standalone is still there to detect on the next request, from
+someone who can act on it, which is what the second half asserts.
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant WP as WordPress
+ participant G as Gatekeeper
+ participant R as Conflict Resolver
+
+ Note over WP: signed in as a subscriber
+ WP->>G: user_may_resolve()
+ G-->>WP: false — cannot activate_plugins
+ WP--xR: no resolver is built
+ Note over WP: same site, now signed in as an administrator
+ WP->>G: user_may_resolve()
+ G-->>WP: true
+ WP->>R: resolve_all() — deactivates and queues
+```
+
+**A reactivation attempt yields the friendly message.** The one conflict the
+load guard cannot prevent: the owner reinstalls the standalone and presses
+Activate, WordPress includes it on top of the bundled copy, and the
+re-declaration is a real fatal that core's sandbox reports as "the plugin
+triggered a fatal error" — true, and useless. All the library gets to do is
+reword the sentence. Driven through core's own filter dispatch rather than by
+calling the rewriter, because the admin-only `add_filter()` is half of what has
+to work. The notice box stays core's — its classes, its dismiss button, its
+wrapper; only the sentence inside is ours.
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant Owner
+ participant WP as WordPress
+ participant Rw as Conflict Rewriter
+
+ Owner->>WP: presses Activate on the standalone
+ WP->>WP: sandbox includes it — re-declaration fatal
+ WP->>WP: redirects to plugins.php with plugin and _error_nonce
+ WP->>Rw: wp_admin_notice_markup filter
+ Rw->>Rw: screen is plugins, arg names a registered standalone, nonce verifies
+ Rw-->>WP: core's sentence swapped for the host's, wrapper untouched
+```
diff --git a/tests/unit/Scenario/ConflictTest.php b/tests/unit/Scenario/ConflictTest.php
new file mode 100644
index 0000000..c057464
--- /dev/null
+++ b/tests/unit/Scenario/ConflictTest.php
@@ -0,0 +1,256 @@
+fatal error.';
+
+ /**
+ * The notice core is about to print, as `wp_admin_notice_markup` hands it over.
+ *
+ * @var string
+ */
+ private const MARKUP = '
';
+
+ /**
+ * Here rather than in the parent because only this file builds an activation-error request: a
+ * `$_GET` left standing would make a later test look like one, and the rewrite would fire on a
+ * screen that never asked for it.
+ *
+ * @return void
+ */
+ public function tearDown(): void {
+ unset( $_GET['plugin'], $_GET['_error_nonce'] );
+
+ parent::tearDown();
+ }
+
+ /**
+ * The default policy, against core's own `deactivate_plugins()` and the real `active_plugins`
+ * option rather than a stub of either.
+ */
+ public function test_deactivate_deactivates_notifies_and_redirects(): void {
+ update_option( 'active_plugins', [ self::STANDALONE ] );
+
+ $this->register(
+ [
+ 'standalone_plugin_basename' => self::STANDALONE,
+ 'conflict_policy' => Conflict_Policy::DEACTIVATE,
+ ]
+ );
+
+ $this->boot();
+
+ $location = $this->run_halted_request();
+
+ $this->assertNotContains( self::STANDALONE, $this->active_plugins() );
+ $this->assertArrayHasKey( self::SLUG . ':merge', $this->queued_notices() );
+
+ // The destination, not merely that one was asked for: a redirect somewhere else entirely
+ // would satisfy "the request ended in a redirect" without sending anyone anywhere useful.
+ $this->assertSame( admin_url( 'plugins.php' ), $location );
+
+ // The request really ended in the resolver. The bundled copy loads on the next one, which is
+ // what the standalone's own guard constant forces in production.
+ $this->assertSame( 0, $this->bundled_plugin_loads() );
+ }
+
+ /**
+ * All the way to the screen. The merge notice is the one this library raises exactly once and
+ * never re-queues, so the admin page load after the deactivation has to draw it — and consume it,
+ * or the owner reads the same deactivation report for ever.
+ */
+ public function test_the_merge_notice_renders_on_the_next_admin_screen_and_clears(): void {
+ update_option( 'active_plugins', [ self::STANDALONE ] );
+
+ $this->register( [ 'standalone_plugin_basename' => self::STANDALONE ] );
+
+ $this->boot();
+ $this->run_halted_request();
+
+ $rendered = $this->render_admin_notices();
+
+ $this->assertStringContainsString( self::SLUG, $rendered );
+ $this->assertStringContainsString( 'has been deactivated', $rendered );
+ $this->assertStringContainsString(
+ 'notice-warning',
+ $rendered,
+ 'A conflict the library has already handled is a warning, not an error.'
+ );
+ $this->assertSame( [], $this->queued_notices(), 'Rendering consumes the queue.' );
+ }
+
+ /**
+ * The failure mode a merge notice queued on every request would produce: a redirect loop, or an
+ * admin screen that reports the same deactivation for ever. Nothing is re-registered between the
+ * two requests — a duplicate slug throws — because this is the next page view, not a second
+ * bootstrap.
+ */
+ public function test_the_request_after_a_deactivation_does_not_loop(): void {
+ update_option( 'active_plugins', [ self::STANDALONE ] );
+
+ $constant = $this->register( [ 'standalone_plugin_basename' => self::STANDALONE ] );
+
+ $this->boot();
+ $this->run_halted_request();
+
+ $this->assertArrayHasKey( self::SLUG . ':merge', $this->queued_notices() );
+
+ // The owner has been told. Emptying the queue is what makes a second notice visible at all:
+ // re-queuing writes the same `slug:merge` key, so a queue left as it is would look identical
+ // whether or not the resolver ran again.
+ $this->clear_notices();
+
+ // This one must not halt, and run_request() fails the test if it does — which is the
+ // redirect loop, asserted rather than described.
+ $this->run_request();
+
+ $this->assertSame( [], $this->queued_notices(), 'Nothing is left to resolve, so nothing is left to say.' );
+ $this->assertSame( 1, $this->bundled_plugin_loads(), 'With the standalone gone the bundled copy takes over.' );
+ $this->assertTrue( defined( $constant ) );
+ }
+
+ /**
+ * DEFER hands the request to the standalone, and WordPress includes an active plugin from
+ * wp-settings.php long before plugins_loaded — so by the time the resolver runs, the standalone
+ * has already defined the guard constant. Defining it up front is what makes this the scenario
+ * the policy actually describes rather than a resolver that merely declined to act.
+ */
+ public function test_defer_leaves_the_standalone_active_and_loads_nothing(): void {
+ update_option( 'active_plugins', [ self::STANDALONE ] );
+
+ $constant = $this->define_guard( 'ABSORBER_E2E_DEFERRED_GUARD' );
+
+ $this->register(
+ [
+ 'standalone_plugin_basename' => self::STANDALONE,
+ 'conflict_policy' => Conflict_Policy::DEFER,
+ ],
+ $constant
+ );
+
+ $this->boot();
+ $this->run_request();
+
+ $this->assertContains( self::STANDALONE, $this->active_plugins() );
+ $this->assertSame( 0, $this->bundled_plugin_loads(), 'The standalone won; the guard stands the bundled copy down.' );
+ $this->assertSame( [], $this->queued_notices() );
+ }
+
+ /**
+ * A policy that only talks: the standalone stays exactly where it was, and the host's own sentence
+ * is what the owner is left with.
+ */
+ public function test_notice_only_notifies_without_deactivating(): void {
+ update_option( 'active_plugins', [ self::STANDALONE ] );
+
+ $this->register(
+ [
+ 'standalone_plugin_basename' => self::STANDALONE,
+ 'conflict_policy' => Conflict_Policy::NOTICE_ONLY,
+ 'conflict_notice_message' => static fn() => 'Deactivate the standalone when you get a chance.',
+ ]
+ );
+
+ $this->boot();
+
+ // A policy that only talks must not end the request, which is what run_request() asserts.
+ $this->run_request();
+
+ $this->assertContains( self::STANDALONE, $this->active_plugins() );
+ $this->assertSame(
+ [ self::SLUG . ':conflict' => 'Deactivate the standalone when you get a chance.' ],
+ $this->queued_notices()
+ );
+ }
+
+ /**
+ * The gate that survives every policy and every rebinding: whoever cannot activate a plugin must
+ * not be able to deactivate one by loading an admin page. Nothing is consumed by refusing — the
+ * standalone is still there to detect on the next request, from someone who can act on it, which
+ * is what the second half asserts.
+ */
+ public function test_a_user_who_cannot_activate_plugins_resolves_nothing(): void {
+ update_option( 'active_plugins', [ self::STANDALONE ] );
+
+ $this->register( [ 'standalone_plugin_basename' => self::STANDALONE ] );
+
+ wp_set_current_user( $this->create_user( 'subscriber' ) );
+
+ $this->boot();
+ $this->run_request();
+
+ $this->assertContains( self::STANDALONE, $this->active_plugins(), 'A subscriber must not deactivate anything.' );
+ $this->assertSame( [], $this->queued_notices(), 'A user who could never read the notice must not consume it.' );
+
+ $this->become_plugin_administrator();
+
+ $this->run_halted_request();
+
+ $this->assertNotContains( self::STANDALONE, $this->active_plugins() );
+ $this->assertArrayHasKey( self::SLUG . ':merge', $this->queued_notices() );
+ }
+
+ /**
+ * The conflict the load guard cannot prevent: the owner reinstalls the standalone and presses
+ * Activate, WordPress includes it on top of the bundled copy, and the re-declaration is a real
+ * fatal that core's sandbox reports as "the plugin triggered a fatal error" — true, and useless.
+ *
+ * Driven through `Absorber::boot()` and core's own filter dispatch rather than by calling
+ * `Conflict\Rewriter` directly, because the wiring is half of what has to work: an admin-only
+ * `add_filter()` that never ran leaves the useless sentence on the screen.
+ */
+ public function test_a_reactivation_attempt_yields_the_friendly_message(): void {
+ $this->register(
+ [
+ 'standalone_plugin_basename' => self::STANDALONE,
+ 'conflict_notice_message' => static fn() => 'Recurring is already bundled with the host plugin.',
+ ]
+ );
+
+ $this->boot();
+
+ // The request core redirects to once the sandboxed activation has fataled.
+ $_GET['plugin'] = self::STANDALONE;
+ $_GET['_error_nonce'] = wp_create_nonce( 'plugin-activation-error_' . self::STANDALONE );
+
+ $rewritten = apply_filters( 'wp_admin_notice_markup', self::MARKUP, self::CORE_TEXT, [] );
+
+ $this->assertIsString( $rewritten, 'The filter must hand back markup, whatever it did with it.' );
+
+ $filtered = is_string( $rewritten ) ? $rewritten : '';
+
+ $this->assertStringContainsString( 'Recurring is already bundled with the host plugin.', $filtered );
+ $this->assertStringNotContainsString( self::CORE_TEXT, $filtered );
+
+ // The notice box stays core's to draw — its classes, its dismiss button, its wrapper. Only
+ // the sentence inside belongs to this library.
+ $this->assertStringStartsWith( '