Repository: JasperFx/wolverine (2026-06-10T18:18:05Z)

Issue:
Add ExternallyOwned() to RabbitMQ queues and exchanges (parity with #3062)
Follow-up to #3062 (Kafka — merged), which added `ExternallyOwned()` so users with consume/produce-only ACLs can stop Wolverine from trying to create or delete topics they don't own. **The same gap exists in the RabbitMQ transport — and it's actually worse than Kafka's pre-#3062 state.**

## What the actual gap looks like

### Exchange side — partial, incorrect coverage

`IRabbitMqExchange.DeclarePassive` exists and is honored at setup (`RabbitMqExchange.cs:151-160`) — uses `ExchangeDeclarePassiveAsync` to verify existence without creating. So far so good.

**But `TeardownAsync` ignores `DeclarePassive` entirely** (`RabbitMqExchange.cs:196-215`) — it just calls `ExchangeDeleteAsync(DeclaredName)` unconditionally:

```csharp
public override async ValueTask TeardownAsync(ILogger logger)
{
    await _parent.WithAdminChannelAsync(async channel =>
    {
        foreach (var binding in _exchangeBindings)
            await binding.TeardownAsync(channel);

        if (DeclaredName != string.Empty)
            await channel.ExchangeDeleteAsync(DeclaredName);   // happens even if DeclarePassive=true
    });
}
```

So a user who set `DeclarePassive=true` to protect an externally-owned exchange will see `resources teardown` try to delete it anyway, fail with `Authorization failed`, and possibly leave the broker in an inconsistent state if the delete partially succeeded against bindings.

### Queue side — no coverage at all

- **No `DeclarePassive` (or any equivalent) property exists on `RabbitMqQueue`.** A grep across `src/Transports/RabbitMQ/Wolverine.RabbitMQ/` returns nothing.
- `RabbitMqQueue.SetupAsync` (line 134-139) always calls `DeclareAsync`. The only escape hatch is `isSystemQueue()`, which is an internal Wolverine concept (reply-uri marker queues, etc.), not a user-facing flag.
- `RabbitMqQueue.TeardownAsync` (line 113-131) only skips for `isSystemQueue() || AutoDelete`. Neither covers "this queue belongs to someone else."

A user doing `ListenToRabbitQueue("vendor-feed")` against a queue they don't own has **no way** to tell Wolverine to leave it alone — they'll hit `Authorization failed` at startup *and* again at teardown.

### Config-layer exposure

Even where the partial coverage exists at the exchange model layer, it isn't exposed where most users author topology:

- `DeclarePassive` lives only on `RabbitMqExchangeConfigurationExpression` (the standalone exchange config).
- It is **not on the listener configuration** (`RabbitMqQueueListenerConfiguration`).
- It is **not on the subscriber configuration**.

So the fluent surface users typically reach for has no `ExternallyOwned()`-equivalent at all.

## Goal

Bring RabbitMQ to parity with the Kafka shape from #3062. Specifically:

1. **Add `IsExternallyOwned` to `RabbitMqQueue`** (new property, default `false`), and honor it symmetrically in `SetupAsync` and `TeardownAsync`. When `true`: no `QueueDeclareAsync`, no `QueueDeleteAsync`, no binding setup/teardown.
2. **Add `IsExternallyOwned` to `RabbitMqExchange`** (new property, separate from `DeclarePassive`), and honor it symmetrically in `SetupAsync` and `TeardownAsync`. When `true`: no `ExchangeDeclareAsync`, no `ExchangeDeleteAsync`, no binding setup/teardown.
3. **Expose `ExternallyOwned()` as a fluent method on the relevant configuration surfaces:**
   - `RabbitMqQueueListenerConfiguration` (`ListenToRabbitQueue(...).ExternallyOwned()`)
   - `RabbitMqQueueSubscriberConfiguration` (`PublishMessage<T>().ToRabbitExchange(...).ExternallyOwned()` and queue variants)
   - `RabbitMqExchangeConfigurationExpression` (alongside the existing `DeclarePassive` setter — see Q1 below)
4. **Decide the relationship between `DeclarePassive` and `ExternallyOwned`** (open question Q1).
5. **Symmetry guarantee**: every place that currently provisions a queue/exchange/binding must check the flag and skip — `SetupAsync`, `TeardownAsync`, `InitializeAsync`, and any binding setup/teardown that runs on behalf of the parent queue or exchange.

## Test matrix (patterned on #3062)

Add `externally_owned_rabbit_topology_is_skipped` in `Wolverine.RabbitMQ.Tests`. Use broker-side existence checks (RabbitMQ management API or `QueueDeclarePassive`/`ExchangeDeclarePassive` against a separate admin connection) rather than try-to-consume-and-see — same defensive design as #3062 used against confluent-local's auto-create.

| # | Scenario | Assertion |
|---|---|---|
| 1 | Listener on a queue marked `ExternallyOwned()`, queue doesn't exist | Queue is **not** created at startup |
| 2 | Subscriber to an exchange marked `ExternallyOwned()`, exchange doesn't exist | Exchange is **not** created at startup |
| 3 | Listener on a queue bound to an exchange where both are `ExternallyOwned()` | Neither queue nor exchange nor binding is touched |
| 4 | Externally-owned + owned queues in the same host | Owned queue still gets created; external is left alone |
| 5 | Externally-owned + owned exchanges in the same host | Same for exchanges |
| 6 | `resources teardown` against an externally-owned listener queue (pre-created out of band) | Queue survives |
| 7 | `resources teardown` against an externally-owned subscriber exchange (pre-created out of band) | Exchange survives |
| 8 | `resources teardown` against a mixed group | Externally-owned survives; owned is deleted |
| 9 | (If Q1 resolves toward keeping both flags) `DeclarePassive=true` + `ExternallyOwned()=false` | Behaviour preserved: setup uses passive declare, teardown still deletes |

## Open questions (for the spike)

### Q1 — `DeclarePassive` vs. `ExternallyOwned`: keep both, or unify?

Two viable shapes:

- **(a) Keep both, separate semantics.** `DeclarePassive` retains its meaning ("touch the broker to verify the resource exists; the calling identity needs at least `read`/`configure` ACL"). `ExternallyOwned` is the strict "don't touch the broker on either side" flag. *Risk: two flags that both look like the same answer; users have to learn the difference.*
- **(b) Subsume `DeclarePassive` under `ExternallyOwned`.** `DeclarePassive` becomes a setter that just sets `IsExternallyOwned=true` (and optionally is marked `[Obsolete]` with a pointer to the new name). One flag, one mental model. *Risk: very small behavioral change for users who relied on the passive-declare verification — they lose the broker-side existence check at startup.*

I lean (a) — they really do mean different things, and the existing `DeclarePassive` behaviour at startup (catch missing-exchange fast at the broker) is genuinely useful when the calling identity *does* have `read` access. The fix is to **document the relationship clearly** in the RabbitMQ guide.

### Q2 — Bindings authored separately from their queue/exchange

Bindings (`RabbitMqBinding`, `RabbitMqExchangeBinding`) are authored on the queue/exchange and have their own `HasDeclared` / setup paths. If a queue is marked `ExternallyOwned()`, its bindings should not be set up or torn down either — that's the conservative reading, and the test matrix above assumes it. Worth confirming during the spike that there's no scenario where a user wants "leave the queue alone but still manage my bindings to it" (unlikely but worth ruling out).

### Q3 — Fix `DeclarePassive` teardown asymmetry while we're in the file?

The exchange-side `DeclarePassive` already-doesn't-guard-teardown bug is a real bug regardless of the new flag. Two choices:
- Fix it here (`if (DeclarePassive) return;` at the top of `TeardownAsync`) as a sibling change.
- Leave it; the new `ExternallyOwned()` flag is the recommended path forward.

I lean fix-it-here as a single-line bug fix — users who already set `DeclarePassive` for protection should keep getting protected, not get migrated away from it by a teardown that deletes their exchange.

## Docs

Mirror the Kafka section structure. New "Externally-Owned Queues and Exchanges" section in `docs/guide/messaging/transports/rabbitmq.md` covering:
- The "no `CreateExchange`/`CreateQueue` ACL" use case.
- Examples for queue-listener, exchange-subscriber, and the mixed external/owned case.
- A "How this relates to `DeclarePassive`" sub-note (resolution from Q1).

## Acceptance criteria

- [ ] `RabbitMqQueue.IsExternallyOwned` added; honored in `SetupAsync`, `TeardownAsync`, and any `InitializeAsync` lifecycle hook.
- [ ] `RabbitMqExchange.IsExternallyOwned` added; honored in `SetupAsync` *and* `TeardownAsync`.
- [ ] `ExternallyOwned()` exposed on `RabbitMqQueueListenerConfiguration`, `RabbitMqQueueSubscriberConfiguration`, and `RabbitMqExchangeConfigurationExpression`.
- [ ] Bindings owned by an externally-owned queue/exchange are not set up or torn down (pending Q2).
- [ ] Q1 settled: documented choice between keeping `DeclarePassive`+`ExternallyOwned` as distinct flags or unifying.
- [ ] Q3 settled: `DeclarePassive` teardown asymmetry either fixed in this PR or explicitly deferred with rationale.
- [ ] Test class `externally_owned_rabbit_topology_is_skipped` with the matrix above passes against a real RabbitMQ broker.
- [ ] Docs section added to `docs/guide/messaging/transports/rabbitmq.md`.
- [ ] `wolverine.slnx -c Release` clean on net9.0 + net10.0.

## Future / out of scope

The same auth-restricted topology gap likely exists in **Azure Service Bus**, **Amazon SQS/SNS**, and **GCP Pub/Sub** — each of those uses `Exists`-check-then-create patterns (or unconditional create) that still require management-level ACLs. Once the RabbitMQ shape settles, the pattern ports cleanly. Worth a *separate* "Add `ExternallyOwned()` to remaining transports for consistent auth-restricted topology handling" tracking issue rather than expanding this one's scope.

## Related

- #3062 — Kafka `ExternallyOwned()`; merged as `2c0d51101`. The shape, test pattern, and docs section in that PR are the model for this work.
- GH-2537 — the original "`AutoProvision` doesn't actually create topics" bug whose fix is what made the auth-gap visible in the first place.



Discussion:

Patch:
diff --git a/docs/guide/messaging/transports/rabbitmq/object-management.md b/docs/guide/messaging/transports/rabbitmq/object-management.md
index 9d0c1b143..1fcf7b3d8 100644
--- a/docs/guide/messaging/transports/rabbitmq/object-management.md
+++ b/docs/guide/messaging/transports/rabbitmq/object-management.md
@@ -143,6 +143,51 @@ return await app.RunJasperFxCommands(args);
 Note that this stateful resource model is also available at the command line as well for deploy time
 management.
 
+## Externally-Owned Queues and Exchanges <Badge type="tip" text="6.6" />
+
+Sometimes a queue or exchange your application uses is owned and managed by a *different* system,
+and the identity your application connects with simply does not have the `configure` or `delete`
+permissions to create or remove it. In that case you want Wolverine to *use* the queue or exchange,
+but never try to declare it at startup (even with `AutoProvision()` turned on) and never delete it
+during a resource teardown. Mark the endpoint as `ExternallyOwned()` to get exactly that behavior:
+
+```csharp
+using var host = await Host.CreateDefaultBuilder()
+    .UseWolverine(opts =>
+    {
+        opts.UseRabbitMq()
+            // AutoProvision is on for the resources this app *does* own...
+            .AutoProvision();
+
+        // ...but this queue belongs to another team. Listen to it,
+        // but never declare it at startup or delete it on teardown,
+        // and don't try to set up or tear down its bindings.
+        opts.ListenToRabbitQueue("shared-orders")
+            .ExternallyOwned();
+
+        // Same idea for an exchange we publish to but do not own
+        opts.PublishMessage<OrderPlaced>()
+            .ToRabbitExchange("shared-events")
+            .ExternallyOwned();
+    }).StartAsync();
+```
+
+When an endpoint is marked `ExternallyOwned()`, Wolverine will:
+
+* Skip declaring the queue or exchange at startup, regardless of `AutoProvision()`
+* Skip deleting it during a `resources teardown` (or the Oakton/JasperFx resource commands)
+* Skip setting up or tearing down any bindings for that resource
+
+This applies to queue listeners, queue subscribers, and exchange subscribers alike.
+
+::: tip
+`ExternallyOwned()` is distinct from `DeclarePassive`. A `DeclarePassive` exchange still makes a
+*passive* declaration against the broker at startup to verify that the resource already exists
+(failing fast if it does not), whereas an externally-owned resource never touches the broker for
+declaration at all. As of Wolverine 6.6, a `DeclarePassive` exchange is also left alone during
+resource teardown — Wolverine will not delete a resource it only verified rather than created.
+:::
+
 ## Exchange-to-Exchange Bindings
 
 Wolverine supports [RabbitMQ exchange-to-exchange bindings](https://www.rabbitmq.com/docs/e2e), which allow you
diff --git a/src/Transports/RabbitMQ/Wolverine.RabbitMQ/Internal/RabbitMqEndpoint.cs b/src/Transports/RabbitMQ/Wolverine.RabbitMQ/Internal/RabbitMqEndpoint.cs
index 2e4a0e911..a4653fdaa 100644
--- a/src/Transports/RabbitMQ/Wolverine.RabbitMQ/Internal/RabbitMqEndpoint.cs
+++ b/src/Transports/RabbitMQ/Wolverine.RabbitMQ/Internal/RabbitMqEndpoint.cs
@@ -23,6 +23,16 @@ internal RabbitMqEndpoint(Uri uri, EndpointRole role, RabbitMqTransport parent)
 
     public string ExchangeName { get; protected set; } = string.Empty;
 
+    /// <summary>
+    /// When <c>true</c>, Wolverine treats this queue or exchange as owned by an external system: it
+    /// will not declare (create) it during startup or delete it during <c>resources teardown</c>, even
+    /// when <c>AutoProvision()</c> is enabled on the parent transport. Bindings owned by an
+    /// externally-owned queue/exchange are likewise left untouched. Use this when the calling identity
+    /// lacks the <c>configure</c>/<c>delete</c> permissions for the resource. Default is <c>false</c>.
+    /// See https://github.com/JasperFx/wolverine/issues/3064.
+    /// </summary>
+    public bool IsExternallyOwned { get; set; }
+
     public abstract ValueTask<bool> CheckAsync();
     public abstract ValueTask TeardownAsync(ILogger logger);
     public abstract ValueTask SetupAsync(ILogger logger);
diff --git a/src/Transports/RabbitMQ/Wolverine.RabbitMQ/Internal/RabbitMqExchange.cs b/src/Transports/RabbitMQ/Wolverine.RabbitMQ/Internal/RabbitMqExchange.cs
index b6e727578..e0b254e50 100644
--- a/src/Transports/RabbitMQ/Wolverine.RabbitMQ/Internal/RabbitMqExchange.cs
+++ b/src/Transports/RabbitMQ/Wolverine.RabbitMQ/Internal/RabbitMqExchange.cs
@@ -122,7 +122,7 @@ public override async ValueTask InitializeAsync(ILogger logger)
             return;
         }
 
-        if (_parent.AutoProvision && !DisableAutoProvision)
+        if (_parent.AutoProvision && !DisableAutoProvision && !IsExternallyOwned)
         {
             await _parent.WithAdminChannelAsync(model => DeclareAsync(model, logger));
         }
@@ -142,7 +142,9 @@ internal override string RoutingKey()
 
     internal async Task DeclareAsync(IChannel channel, ILogger logger)
     {
-        if (DeclaredName == string.Empty)
+        // Externally-owned exchanges are declared/managed by another system; don't touch the broker
+        // here at all (neither the exchange nor its source-exchange bindings below). GH-3064.
+        if (DeclaredName == string.Empty || IsExternallyOwned)
         {
             return;
         }
@@ -195,6 +197,15 @@ public override async ValueTask<bool> CheckAsync()
 
     public override async ValueTask TeardownAsync(ILogger logger)
     {
+        // Don't delete an exchange we don't own. IsExternallyOwned is the explicit flag for that;
+        // DeclarePassive is honored here too — it means "only verify existence, never create" at setup,
+        // so deleting on teardown would be asymmetric and would destroy a resource the caller chose not
+        // to create. Bindings are likewise left alone. GH-3064 (DeclarePassive teardown fix included).
+        if (IsExternallyOwned || DeclarePassive)
+        {
+            return;
+        }
+
         await _parent.WithAdminChannelAsync(async channel =>
         {
             foreach (var binding in _exchangeBindings)
diff --git a/src/Transports/RabbitMQ/Wolverine.RabbitMQ/Internal/RabbitMqQueue.cs b/src/Transports/RabbitMQ/Wolverine.RabbitMQ/Internal/RabbitMqQueue.cs
index 7bf9ee3da..cdfa876aa 100644
--- a/src/Transports/RabbitMQ/Wolverine.RabbitMQ/Internal/RabbitMqQueue.cs
+++ b/src/Transports/RabbitMQ/Wolverine.RabbitMQ/Internal/RabbitMqQueue.cs
@@ -112,8 +112,9 @@ public override async ValueTask<bool> CheckAsync()
 
     public override async ValueTask TeardownAsync(ILogger logger)
     {
-        // This is a reply uri owned by another node, so get out of here
-        if (isSystemQueue() || AutoDelete)
+        // This is a reply uri owned by another node, so get out of here. Externally-owned queues
+        // belong to another system and must not be deleted (nor their bindings torn down). GH-3064.
+        if (isSystemQueue() || AutoDelete || IsExternallyOwned)
         {
             return;
         }
@@ -133,7 +134,8 @@ await _parent.WithAdminChannelAsync(async channel =>
 
     public override async ValueTask SetupAsync(ILogger logger)
     {
-        if (isSystemQueue())
+        // Externally-owned queues are declared/managed by another system; don't try to create them. GH-3064.
+        if (isSystemQueue() || IsExternallyOwned)
         {
             return;
         }
@@ -270,7 +272,9 @@ internal async ValueTask InitializeAsync(IChannel channel, ILogger logger)
 
         if (_parent.AutoProvision || _parent.AutoPurgeAllQueues || PurgeOnStartup)
         {
-            if (_parent.AutoProvision)
+            // Externally-owned queues (and their bindings) are managed by another system; skip the
+            // declare even when AutoProvision is on so startup doesn't fail without configure ACLs. GH-3064.
+            if (_parent.AutoProvision && !IsExternallyOwned)
             {
                 await DeclareAsync(channel, logger);
             }
diff --git a/src/Transports/RabbitMQ/Wolverine.RabbitMQ/RabbitMqExchangeConfigurationExpression.cs b/src/Transports/RabbitMQ/Wolverine.RabbitMQ/RabbitMqExchangeConfigurationExpression.cs
index 250b9b39f..5fb7de9e3 100644
--- a/src/Transports/RabbitMQ/Wolverine.RabbitMQ/RabbitMqExchangeConfigurationExpression.cs
+++ b/src/Transports/RabbitMQ/Wolverine.RabbitMQ/RabbitMqExchangeConfigurationExpression.cs
@@ -33,6 +33,21 @@ public bool DeclarePassive
         get => _exchange.DeclarePassive;
         set => _exchange.DeclarePassive = value;
     }
+
+    /// <summary>
+    /// When <c>true</c>, Wolverine treats this exchange as owned by an external system: it will not
+    /// declare (create) it at startup or delete it during <c>resources teardown</c>, even when
+    /// <c>AutoProvision()</c> is enabled, and will not set up or tear down its bindings. Use this when
+    /// the calling identity lacks the <c>configure</c>/<c>delete</c> permissions for the exchange.
+    /// Distinct from <see cref="DeclarePassive"/>, which still touches the broker to verify existence
+    /// at startup. See https://github.com/JasperFx/wolverine/issues/3064.
+    /// </summary>
+    public bool IsExternallyOwned
+    {
+        get => _exchange.IsExternallyOwned;
+        set => _exchange.IsExternallyOwned = value;
+    }
+
     public IDictionary<string, object?> Arguments => _exchange.Arguments;
     public TopicBindingExchange BindTopic(string topicPattern)
     {
diff --git a/src/Transports/RabbitMQ/Wolverine.RabbitMQ/RabbitMqListenerConfiguration.cs b/src/Transports/RabbitMQ/Wolverine.RabbitMQ/RabbitMqListenerConfiguration.cs
index c19ed715f..83996e44b 100644
--- a/src/Transports/RabbitMQ/Wolverine.RabbitMQ/RabbitMqListenerConfiguration.cs
+++ b/src/Transports/RabbitMQ/Wolverine.RabbitMQ/RabbitMqListenerConfiguration.cs
@@ -112,6 +112,18 @@ public RabbitMqListenerConfiguration PreFetchCount(ushort count)
         return this;
     }
 
+    /// <summary>
+    /// Mark this queue as owned by an external system. Wolverine will not declare (create) the queue
+    /// at startup or delete it during <c>resources teardown</c>, even when <c>AutoProvision()</c> is
+    /// enabled, and will not set up or tear down its bindings. Use this when the calling identity lacks
+    /// the <c>configure</c>/<c>delete</c> permissions for the queue. See https://github.com/JasperFx/wolverine/issues/3064.
+    /// </summary>
+    public RabbitMqListenerConfiguration ExternallyOwned()
+    {
+        add(e => e.IsExternallyOwned = true);
+        return this;
+    }
+
     /// <summary>
     /// Use a custom interoperability strategy to map Wolverine messages to an upstream
     /// system's protocol
diff --git a/src/Transports/RabbitMQ/Wolverine.RabbitMQ/RabbitMqSubscriberConfiguration.cs b/src/Transports/RabbitMQ/Wolverine.RabbitMQ/RabbitMqSubscriberConfiguration.cs
index fe7152875..0096ae403 100644
--- a/src/Transports/RabbitMQ/Wolverine.RabbitMQ/RabbitMqSubscriberConfiguration.cs
+++ b/src/Transports/RabbitMQ/Wolverine.RabbitMQ/RabbitMqSubscriberConfiguration.cs
@@ -72,6 +72,19 @@ public RabbitMqSubscriberConfiguration DisableDeadLetterQueueing()
 
         return this;
     }
+
+    /// <summary>
+    /// Mark the target queue or exchange as owned by an external system. Wolverine will not declare
+    /// (create) it at startup or delete it during <c>resources teardown</c>, even when
+    /// <c>AutoProvision()</c> is enabled, and will not set up or tear down its bindings. Use this when
+    /// the calling identity lacks the <c>configure</c>/<c>delete</c> permissions for the resource.
+    /// See https://github.com/JasperFx/wolverine/issues/3064.
+    /// </summary>
+    public RabbitMqSubscriberConfiguration ExternallyOwned()
+    {
+        add(e => e.IsExternallyOwned = true);
+        return this;
+    }
 }
 
 public class RabbitMqExchangeConfiguration : InteroperableSubscriberConfiguration<RabbitMqExchangeConfiguration, RabbitMqExchange, IRabbitMqEnvelopeMapper, RabbitMqEnvelopeMapper>
@@ -101,6 +114,23 @@ public RabbitMqExchangeConfiguration UseNServiceBusInterop()
         return this;
     }
 
+    /// <summary>
+    ///     Modify the exchange type, the default is fan out
+    /// </summary>
+    /// <param name="exchangeType"></param>
+    /// <returns></returns>
+    /// <summary>
+    /// Mark this exchange as owned by an external system. Wolverine will not declare (create) it at
+    /// startup or delete it during <c>resources teardown</c>, even when <c>AutoProvision()</c> is
+    /// enabled, and will not set up or tear down its bindings. Use this when the calling identity lacks
+    /// the <c>configure</c>/<c>delete</c> permissions for the exchange. See https://github.com/JasperFx/wolverine/issues/3064.
+    /// </summary>
+    public RabbitMqExchangeConfiguration ExternallyOwned()
+    {
+        add(e => e.IsExternallyOwned = true);
+        return this;
+    }
+
     /// <summary>
     ///     Modify the exchange type, the default is fan out
     /// </summary>


Test patch:
diff --git a/src/Transports/RabbitMQ/Wolverine.RabbitMQ.Tests/externally_owned_rabbit_topology_is_skipped.cs b/src/Transports/RabbitMQ/Wolverine.RabbitMQ.Tests/externally_owned_rabbit_topology_is_skipped.cs
new file mode 100644
index 000000000..de85c7408
--- /dev/null
+++ b/src/Transports/RabbitMQ/Wolverine.RabbitMQ.Tests/externally_owned_rabbit_topology_is_skipped.cs
@@ -0,0 +1,175 @@
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging.Abstractions;
+using RabbitMQ.Client;
+using Shouldly;
+using Wolverine.ComplianceTests;
+using Wolverine.RabbitMQ.Internal;
+using Xunit;
+
+namespace Wolverine.RabbitMQ.Tests;
+
+// GH-3064: a queue or exchange marked ExternallyOwned() must never be declared (created) at startup
+// nor deleted during resource teardown, even with AutoProvision on - the escape hatch for topology
+// owned by another system where the calling identity lacks configure/delete permissions. Assertions
+// hit the broker directly (passive declare on a separate admin connection) rather than relying on
+// Wolverine's in-memory model.
+public class externally_owned_rabbit_topology_is_skipped : IAsyncLifetime
+{
+    private readonly string _externalQueue = "ext-queue-" + Guid.NewGuid().ToString("N");
+    private readonly string _externalExchange = "ext-exchange-" + Guid.NewGuid().ToString("N");
+    private readonly string _ownedQueue = "owned-queue-" + Guid.NewGuid().ToString("N");
+    private readonly string _passiveExchange = "passive-exchange-" + Guid.NewGuid().ToString("N");
+
+    public Task InitializeAsync() => Task.CompletedTask;
+
+    public async Task DisposeAsync()
+    {
+        try
+        {
+            await using var conn = await new ConnectionFactory { HostName = "localhost" }.CreateConnectionAsync();
+            await using var channel = await conn.CreateChannelAsync();
+            foreach (var q in new[] { _externalQueue, _ownedQueue })
+            {
+                try { await channel.QueueDeleteAsync(q, false, false); } catch { }
+            }
+            foreach (var e in new[] { _externalExchange, _passiveExchange })
+            {
+                try { await channel.ExchangeDeleteAsync(e); } catch { }
+            }
+        }
+        catch
+        {
+            // ignore cleanup failures
+        }
+    }
+
+    // --- broker-side existence checks via a separate admin connection ---
+
+    private static async Task<bool> QueueExistsAsync(string name)
+    {
+        await using var conn = await new ConnectionFactory { HostName = "localhost" }.CreateConnectionAsync();
+        await using var channel = await conn.CreateChannelAsync();
+        try { await channel.QueueDeclarePassiveAsync(name); return true; }
+        catch { return false; } // passive declare on a missing queue closes the channel with a 404
+    }
+
+    private static async Task<bool> ExchangeExistsAsync(string name)
+    {
+        await using var conn = await new ConnectionFactory { HostName = "localhost" }.CreateConnectionAsync();
+        await using var channel = await conn.CreateChannelAsync();
+        try { await channel.ExchangeDeclarePassiveAsync(name); return true; }
+        catch { return false; }
+    }
+
+    private static async Task PreCreateQueueAsync(string name)
+    {
+        await using var conn = await new ConnectionFactory { HostName = "localhost" }.CreateConnectionAsync();
+        await using var channel = await conn.CreateChannelAsync();
+        await channel.QueueDeclareAsync(name, durable: true, exclusive: false, autoDelete: false);
+    }
+
+    private static async Task PreCreateExchangeAsync(string name)
+    {
+        await using var conn = await new ConnectionFactory { HostName = "localhost" }.CreateConnectionAsync();
+        await using var channel = await conn.CreateChannelAsync();
+        await channel.ExchangeDeclareAsync(name, type: "fanout", durable: true, autoDelete: false);
+    }
+
+    // === Setup: externally-owned topology is never created ===
+
+    [Fact]
+    public async Task externally_owned_queue_is_not_created_at_startup()
+    {
+        using var host = await WolverineHost.ForAsync(opts =>
+        {
+            opts.UseRabbitMq().AutoProvision();
+            opts.PublishMessage<ExtMessage>().ToRabbitQueue(_externalQueue).ExternallyOwned();
+        });
+
+        (await QueueExistsAsync(_externalQueue)).ShouldBeFalse();
+    }
+
+    [Fact]
+    public async Task externally_owned_exchange_is_not_created_at_startup()
+    {
+        using var host = await WolverineHost.ForAsync(opts =>
+        {
+            opts.UseRabbitMq().AutoProvision();
+            opts.PublishMessage<ExtMessage>().ToRabbitExchange(_externalExchange).ExternallyOwned();
+        });
+
+        (await ExchangeExistsAsync(_externalExchange)).ShouldBeFalse();
+    }
+
+    [Fact]
+    public async Task owned_topology_is_still_created_alongside_externally_owned()
+    {
+        using var host = await WolverineHost.ForAsync(opts =>
+        {
+            opts.UseRabbitMq().AutoProvision();
+            opts.PublishMessage<ExtMessage>().ToRabbitQueue(_externalQueue).ExternallyOwned();
+            opts.PublishMessage<OwnedMessage>().ToRabbitQueue(_ownedQueue); // owned -> should be created
+        });
+
+        (await QueueExistsAsync(_ownedQueue)).ShouldBeTrue();
+        (await QueueExistsAsync(_externalQueue)).ShouldBeFalse();
+    }
+
+    // === Teardown: externally-owned topology survives ===
+
+    [Fact]
+    public async Task teardown_leaves_an_externally_owned_listener_queue_alone()
+    {
+        await PreCreateQueueAsync(_externalQueue);
+
+        using var host = await WolverineHost.ForAsync(opts =>
+        {
+            opts.UseRabbitMq();
+            opts.ListenToRabbitQueue(_externalQueue).ExternallyOwned();
+        });
+
+        var queue = host.Get<WolverineOptions>().RabbitMqTransport().Queues[_externalQueue];
+        await queue.TeardownAsync(NullLogger.Instance);
+
+        (await QueueExistsAsync(_externalQueue)).ShouldBeTrue();
+    }
+
+    [Fact]
+    public async Task teardown_leaves_an_externally_owned_exchange_alone()
+    {
+        await PreCreateExchangeAsync(_externalExchange);
+
+        using var host = await WolverineHost.ForAsync(opts =>
+        {
+            opts.UseRabbitMq();
+            opts.PublishMessage<ExtMessage>().ToRabbitExchange(_externalExchange).ExternallyOwned();
+        });
+
+        var exchange = host.Get<WolverineOptions>().RabbitMqTransport().Exchanges[_externalExchange];
+        await exchange.TeardownAsync(NullLogger.Instance);
+
+        (await ExchangeExistsAsync(_externalExchange)).ShouldBeTrue();
+    }
+
+    // === Q3: DeclarePassive exchanges must also survive teardown (no longer deleted) ===
+
+    [Fact]
+    public async Task teardown_leaves_a_declare_passive_exchange_alone()
+    {
+        await PreCreateExchangeAsync(_passiveExchange);
+
+        using var host = await WolverineHost.ForAsync(opts =>
+        {
+            opts.PublishMessage<ExtMessage>().ToRabbitExchange(_passiveExchange, ex => ex.DeclarePassive = true);
+        });
+
+        var exchange = host.Get<WolverineOptions>().RabbitMqTransport().Exchanges[_passiveExchange];
+        await exchange.TeardownAsync(NullLogger.Instance);
+
+        (await ExchangeExistsAsync(_passiveExchange)).ShouldBeTrue();
+    }
+}
+
+public record ExtMessage(string Name);
+
+public record OwnedMessage(string Name);