Commissioning Proxy PR2: Add CP Cluster Server and Unit Tests  (#72625)

* CommissioningProxy: add cluster definition and generated code

* CommissioningProxy: add cluster server, delegate and unit tests

* Remove PythonProxyCommissioner, CHI, add CachedResults

* Adds missing CP auto-gen config

* Cluster Delegate Code Review

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Updates after CP Gemini code review

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* CommissioningProxy: register cluster XML in ZAP codegen config

Add the generated registration for commissioning-proxy-cluster.xml that
was missing from the codegen config:

- zap_cluster_list.json: add COMMISSIONING_PROXY_CLUSTER (empty server
  dir; server entry deferred to the cluster-impl PR)
- tests.yaml: add the XML to the CI "Validate that xml are parsable" list
- commissioning-proxy-cluster.xml: regenerate header with
  attribute=in-progress; Git provenance now clean (no -dirty)

* Rebuild autogen files

* Removes CP active Python code

* [commissioning-proxy] Address PR2 review feedback

- Cluster owns ScanMaxTime/CacheTimeout storage + change reporting (delegate no longer forwards them).
- Command handlers return std::optional to own their async/sync response.
- Move endpoint id to the constructor; rename State_t -> State; default mock ctor/dtor in header.
- Remove dead CHIP_DEVICE_CONFIG_ENABLE_COMMISSIONING_PROXY test define.
- Rename backwards-compat suite -> codegen-integration and run it via src/BUILD.gn.
- Tests: drop 2 duplicate BgScan cases, add ScanMaxTime/CacheTimeout change-reporting tests.

* [commissioning-proxy] Replace delegate with transport-driver interface + subsystems

The cluster now owns all transport-agnostic bookkeeping via three internal
subsystems (SessionManager, ScanCache, ScanAggregator). Physical transport
work is handled by CommissioningProxyTransport driver objects registered via
RegisterTransport(); GetSupportedTransports() ORs all registered drivers.

CommissioningProxyDelegate.h is removed; NotifyAttributeChanged for writable
attributes (ScanMaxTime, CacheTimeout) is now always the cluster's responsibility.

Tests updated to use CommissioningProxyMockTransport (93 cases).

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* [commissioning-proxy] Fix GN include-checker failure in CommissioningProxyScanCache

CommissioningProxyScanCache.cpp included CommissioningProxyCluster.h, which
is intentionally excluded from the commissioning-proxy-server source_set
(compiled in the consumer's context via app_config_dependent_sources.gni).
GN's include-checker rejected it, failing ESP32, Linux, and ARM cross-compile.

Introduce ScanCacheObserver (three pure-virtual callbacks) in
CommissioningProxyScanCache.h. CommissioningProxyCluster inherits it;
the scan cache stores ScanCacheObserver& instead of CommissioningProxyCluster&.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* [commissioning-proxy] Fix ASAN stack-use-after-scope in TestProxyMessageRequest_DuplicateRequest_Busy

CommissioningProxyCluster did not override Shutdown(). The explicit
cluster.Shutdown() call in tests only cleared mContext; subsystem teardown
only happened in the destructor. C++ destroys tester (which owns
MockCommandHandler) before cluster, so the destructor's mSessions.Shutdown()
→ delete pm → Handle::Release() reached a dead CommandHandler.

Override Shutdown() to clean up subsystems and transports. The destructor
delegates to it as an idempotent safety net (mTransportCount=0 guards the loop).

* [commissioning-proxy] Fix clang-tidy warnings in TestCommissioningProxyCluster

bugprone-argument-comment: argument comment must match the parameter
name exactly — /*maxSessions=*/ → /*aMaxSessions=*/.

bugprone-unchecked-optional-access: clang-tidy's dataflow analysis does
not see through the ASSERT_TRUE macro, so response-> after
ASSERT_TRUE(response.has_value()) is flagged. Replace with
response.value().field which throws bad_optional_access instead of UB
and is not flagged by the checker.

* [commissioning-proxy] Fix bugprone-unchecked-optional-access in tests

clang-tidy's dataflow analysis does not see through ASSERT_TRUE macros,
so response.value() after ASSERT_TRUE(response.has_value()) is still
flagged. Wrap each access in an explicit if (response.has_value()) block,
which creates visible control flow the checker can follow.

* [commissioning-proxy] Add braces to all bare if/for/while bodies

GCC 14.2 on Tizen raises -Werror=dangling-else when a brace-free
if body is a GTest EXPECT_* macro that expands to if/else internally.
Add explicit braces to all single-statement if/else-if/for/while
bodies across commissioning-proxy-server/ (46 sites in 7 files).

Also fix README: remove unverified spec section number, update
argument-comment names to match Config constructor parameters.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* [commissioning-proxy] Add shared background-scan fabric registry

Extract the transport-agnostic ProxyBackGroundScan bookkeeping (per-fabric
transport/band records, lifetime timers, Start/Stop overlap arithmetic,
paused/deferred state) into CommissioningProxyBgScanRegistry so the BLE and
PAF transports stop duplicating it; transports supply only hardware
start/stop/clear hooks via HardwareControl. Adds 15 unit tests.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* [commissioning-proxy] Document background-scan registry, fix build layout

Add CommissioningProxyBgScanRegistry to the README's subsystem and
architecture tables, fix the BgScanStart/BgScanStop signatures in the
driver example, and document its API and HardwareControl contract.

The cluster reads no transport build flags, so move
CommissioningProxyCluster.{h,cpp} into the cluster source_set and out of
app_config_dependent_sources.{gni,cmake}, matching chime-server, and
drop the stale comment naming the deleted CommissioningProxyDelegate.h.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Use WritAttribute and remove redundant comment

- WriteAttribute: use DefaultServerCluster::SetAttributeValue for
  ScanMaxTime and CacheTimeout instead of hand-rolling the
  compare/assign/NotifyAttributeChanged pattern. No behaviour change.
- tests/BUILD.gn: drop the historical backwards-compatibility note from
  the tests-codegen-integration comment

* CodeRabbitAI first checnges fix

Add the smaller fixes identified.

* Inject a TimerDelegate so cluster timer paths are testable

The session manager and scan aggregator called SystemLayer directly, so
tests could not drive expiry or exercise a StartTimer failure.

Both now take a chip::TimerDelegate; the aggregator and each pending
message are TimerContexts, and CodegenIntegration supplies the shared
DefaultTimerDelegate, so the cluster ctor gains a third parameter.
Adds CommissioningProxyMockTimer (multi-context with failure injection)
and 3 tests for the rollback paths, each confirmed to fail without them.

* Add new tests to use TimerDelegate

Adds 7 timer unit tests tests for bgscan lifetime expiry and
timer-arm failure, independent per-fabric lifetimes, cache TTL expiry,
rediscovery resetting the TTL, sweep re-arming, and the watchdog ending
a stalled aggregation. Each confirmed to fail against broken code.

* Make a rejected background-scan start leave nothing behind

BgScanRegistry::Start mutated the fabric table before the hardware start
and lifetime timer could fail, so a failed refresh destroyed a working
registration and cancelled its timer. Both fallible steps now run first
and the record is only replaced on success.

ProxyBackGroundScanStartRequest returned the first error while leaving
already-started transports scanning; those are now stopped on failure.

* Make background-scan records per fabric

Spec says "keep per fabric records", recording the sender's NodeID only to
authorise the matching Stop. Keying on the pair grew the table with fabrics
x nodes. The NodeID now sits in the record, Stop rejects a non-owner, and a
start from another node on the same fabric takes ownership.

* Tidy unsued #include and small tweaks

* Trim comments.

* Scan the union of each fabric's background-scan requests

Each node keeps its own request (a Stop is identified by NodeID and FabricID);
the fabric scans their union under one timer at the latest deadline, recomputed
on Stop, capped at 4 requests. Clearing is band-scoped: ClearCachedResults(bands).

* Give the CommissioningProxy cluster bounded, fabric-aware state

Added removed fabric's sessions, connect and background scans. MaxSessions and
MaxCachedResults now come from CHIPConfig.h, sizing the heap-free storage that
replaces std::map and raw new.

* Clear fabric request slot on re-arm failure

Add new tests and update REAME.md

* Fix CommissioningProxy lifecycle and validation gaps from review

Release a background-scan fabric whose lifetime timer cannot be re-armed
so a failed refresh cannot leave its requests scanning unbounded. Reject
reserved Transport bits in ProxyConnectRequest with InvalidCommand, as
the scan commands already does. Cancel the scan cache's sweep timer
from its destructor. Leave the mock transport's unanswered message
pending so the response timer is what resolves it.

* Consolidate CommissioningProxy unit tests

Merge or drop tests that re-asserted a covered path, table the read-only and
min-1 attribute checks, and give the registry tests a fixture. Add a
cross-command reserved-Transport-bit test; note why MaxSessions stays above 1.

* CP: Make MAX_SESSIONS default=1

* Add compile-time bounds on the three CP config macros

* Address PR review feedback on the Commissioning Proxy cluster server

Drop the unused CodegenIntegration Instance wrapper, its test target and the
mock_ember carve-out it needed. Type the transport interface's seconds params
as System::Clock::Seconds16, rename the session lookups, tidy the registry.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
diff --git a/src/BUILD.gn b/src/BUILD.gn
index 87c4064..7bdad0d 100644
--- a/src/BUILD.gn
+++ b/src/BUILD.gn
@@ -74,6 +74,7 @@
       "${chip_root}/src/app/clusters/closure-control-server/tests",
       "${chip_root}/src/app/clusters/closure-dimension-server/tests",
       "${chip_root}/src/app/clusters/commissioner-control-server/tests",
+      "${chip_root}/src/app/clusters/commissioning-proxy-server/tests",
       "${chip_root}/src/app/clusters/commodity-tariff-server/tests",
       "${chip_root}/src/app/clusters/concentration-measurement-server/tests",
       "${chip_root}/src/app/clusters/content-launch-server/tests",
@@ -188,8 +189,9 @@
       ]
     }
 
-    # Backwards compatibility uses ember mocks, so they cannot run in a "unified"
-    # test build. Restrict them to specific platforms only
+    # These suites link ember mocks (codegen data-model provider), so they cannot
+    # run in a "unified" single-binary test build. Restrict them to specific
+    # platforms only.
     if (chip_device_platform == "darwin" || chip_device_platform == "linux") {
       tests += [
         # keep-sorted: start
diff --git a/src/app/clusters/commissioning-proxy-server/BUILD.gn b/src/app/clusters/commissioning-proxy-server/BUILD.gn
new file mode 100644
index 0000000..63cc9e9
--- /dev/null
+++ b/src/app/clusters/commissioning-proxy-server/BUILD.gn
@@ -0,0 +1,50 @@
+# Copyright (c) 2026 Project CHIP Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+import("//build_overrides/build.gni")
+import("//build_overrides/chip.gni")
+
+source_set("commissioning-proxy-server") {
+  # The cluster and its subsystems are independent of the application
+  # configuration: the transports the cluster advertises come from the drivers
+  # registered at runtime, not from build flags. Only the code-generation
+  # integration is application-config dependent, so CodegenIntegration.{cpp,h}
+  # is listed in app_config_dependent_sources.{gni,cmake} instead.
+  sources = [
+    "CommissioningProxyBgScanRegistry.cpp",
+    "CommissioningProxyBgScanRegistry.h",
+    "CommissioningProxyCluster.cpp",
+    "CommissioningProxyCluster.h",
+    "CommissioningProxyScanAggregator.cpp",
+    "CommissioningProxyScanAggregator.h",
+    "CommissioningProxyScanCache.cpp",
+    "CommissioningProxyScanCache.h",
+    "CommissioningProxySessionManager.cpp",
+    "CommissioningProxySessionManager.h",
+    "CommissioningProxyTransport.h",
+  ]
+
+  cflags = [ "-Wconversion" ]
+
+  public_deps = [
+    "${chip_root}/src/app/data-model-provider",
+    "${chip_root}/src/app/server",
+    "${chip_root}/src/app/server-cluster",
+    "${chip_root}/src/app/server-cluster:registry",
+    "${chip_root}/src/data-model-providers/codegen:headers",
+    "${chip_root}/src/lib/support:timer-delegate",
+    "${chip_root}/src/messaging",
+    "${chip_root}/src/platform",
+    "${chip_root}/zzz_generated/app-common/clusters/CommissioningProxy",
+  ]
+}
diff --git a/src/app/clusters/commissioning-proxy-server/CodegenIntegration.cpp b/src/app/clusters/commissioning-proxy-server/CodegenIntegration.cpp
new file mode 100644
index 0000000..a76b6d1
--- /dev/null
+++ b/src/app/clusters/commissioning-proxy-server/CodegenIntegration.cpp
@@ -0,0 +1,25 @@
+/*
+ *    Copyright (c) 2026 Project CHIP Authors
+ *
+ *    Licensed under the Apache License, Version 2.0 (the "License");
+ *    you may not use this file except in compliance with the License.
+ *    You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *    Unless required by applicable law or agreed to in writing, software
+ *    distributed under the License is distributed on an "AS IS" BASIS,
+ *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *    See the License for the specific language governing permissions and
+ *    limitations under the License.
+ */
+
+#include <app/clusters/commissioning-proxy-server/CodegenIntegration.h>
+#include <app/util/af-types.h>
+
+using namespace chip;
+
+void MatterCommissioningProxyClusterInitCallback(EndpointId endpointId) {}
+void MatterCommissioningProxyClusterShutdownCallback(EndpointId endpointId, MatterClusterShutdownType shutdownType) {}
+void MatterCommissioningProxyPluginServerInitCallback() {}
+void MatterCommissioningProxyPluginServerShutdownCallback() {}
diff --git a/src/app/clusters/commissioning-proxy-server/CodegenIntegration.h b/src/app/clusters/commissioning-proxy-server/CodegenIntegration.h
new file mode 100644
index 0000000..d5ec379
--- /dev/null
+++ b/src/app/clusters/commissioning-proxy-server/CodegenIntegration.h
@@ -0,0 +1,26 @@
+/*
+ *
+ *    Copyright (c) 2026 Project CHIP Authors
+ *    All rights reserved.
+ *
+ *    Licensed under the Apache License, Version 2.0 (the "License");
+ *    you may not use this file except in compliance with the License.
+ *    You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *    Unless required by applicable law or agreed to in writing, software
+ *    distributed under the License is distributed on an "AS IS" BASIS,
+ *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *    See the License for the specific language governing permissions and
+ *    limitations under the License.
+ */
+
+#pragma once
+
+#include <app/clusters/commissioning-proxy-server/CommissioningProxyCluster.h>
+
+// CommissioningProxyCluster does not provide a legacy API for ember integration.
+// Applications using the code-gen data model should use RegisteredServerCluster or
+// LazyRegisteredServerCluster to manage a CommissioningProxyCluster instance, and
+// register their transport drivers on it before registering the cluster.
diff --git a/src/app/clusters/commissioning-proxy-server/CommissioningProxyBgScanRegistry.cpp b/src/app/clusters/commissioning-proxy-server/CommissioningProxyBgScanRegistry.cpp
new file mode 100644
index 0000000..e5d0ba9
--- /dev/null
+++ b/src/app/clusters/commissioning-proxy-server/CommissioningProxyBgScanRegistry.cpp
@@ -0,0 +1,519 @@
+/*
+ *
+ *    Copyright (c) 2026 Project CHIP Authors
+ *    All rights reserved.
+ *
+ *    Licensed under the Apache License, Version 2.0 (the "License");
+ *    you may not use this file except in compliance with the License.
+ *    You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *    Unless required by applicable law or agreed to in writing, software
+ *    distributed under the License is distributed on an "AS IS" BASIS,
+ *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *    See the License for the specific language governing permissions and
+ *    limitations under the License.
+ */
+
+#include "CommissioningProxyBgScanRegistry.h"
+
+#include <lib/support/logging/CHIPLogging.h>
+#include <system/SystemClock.h>
+
+#include <algorithm>
+#include <chrono>
+
+namespace chip {
+namespace app {
+namespace Clusters {
+namespace CommissioningProxy {
+
+using Protocols::InteractionModel::Status;
+
+namespace {
+// Timestamp subtraction is 64-bit; StartTimer takes the 32-bit Timeout.
+System::Clock::Timeout RemainingUntil(System::Clock::Timestamp deadline, System::Clock::Timestamp now)
+{
+    return (deadline > now) ? std::chrono::duration_cast<System::Clock::Timeout>(deadline - now) : System::Clock::Timeout(0);
+}
+} // namespace
+
+size_t CommissioningProxyBgScanRegistry::FabricState::RequestCount() const
+{
+    size_t count = 0;
+    for (const auto & slot : requests)
+    {
+        if (slot.inUse)
+        {
+            count++;
+        }
+    }
+    return count;
+}
+
+CommissioningProxyBgScanRegistry::RequestSlot * CommissioningProxyBgScanRegistry::FabricState::Find(NodeId nodeId)
+{
+    for (auto & slot : requests)
+    {
+        if (slot.inUse && slot.nodeId == nodeId)
+        {
+            return &slot;
+        }
+    }
+    return nullptr;
+}
+
+CommissioningProxyBgScanRegistry::RequestSlot * CommissioningProxyBgScanRegistry::FabricState::FindFree()
+{
+    for (auto & slot : requests)
+    {
+        if (!slot.inUse)
+        {
+            return &slot;
+        }
+    }
+    return nullptr;
+}
+
+CommissioningProxyBgScanRegistry::FabricState * CommissioningProxyBgScanRegistry::FindFabric(FabricIndex fabricIndex)
+{
+    for (auto & fabric : mFabrics)
+    {
+        if (fabric.inUse && fabric.fabricIndex == fabricIndex)
+        {
+            return &fabric;
+        }
+    }
+    return nullptr;
+}
+
+bool CommissioningProxyBgScanRegistry::AnyFabricInUse() const
+{
+    for (const auto & fabric : mFabrics)
+    {
+        if (fabric.inUse)
+        {
+            return true;
+        }
+    }
+    return false;
+}
+
+void CommissioningProxyBgScanRegistry::CancelLifetime(FabricState & state)
+{
+    if (state.lifetime.armed)
+    {
+        mTimerDelegate.CancelTimer(&state.lifetime);
+        state.lifetime.armed = false;
+    }
+}
+
+void CommissioningProxyBgScanRegistry::OnBecameEmpty()
+{
+    // Stop the radio only if this registry currently owns it; if paused, a connect or
+    // foreground scan owns it and must not be disturbed. The cached results are dropped
+    // in either case — no fabric wants them refreshed any more.
+    if (!mPaused)
+    {
+        mHardware.StopHardwareScan();
+    }
+    mPaused = false;
+    mHardware.ClearCachedResults({}); // the transport stopped, so all of its results go
+}
+
+BitMask<WiFiBandBitmap> CommissioningProxyBgScanRegistry::BandsInUse() const
+{
+    uint16_t bits = 0;
+    for (const auto & fabric : mFabrics)
+    {
+        if (!fabric.inUse)
+        {
+            continue;
+        }
+        for (const auto & slot : fabric.requests)
+        {
+            if (slot.inUse)
+            {
+                bits = static_cast<uint16_t>(bits | slot.request.wiFiBands.Raw());
+            }
+        }
+    }
+    return BitMask<WiFiBandBitmap>(bits);
+}
+
+void CommissioningProxyBgScanRegistry::ClearBandsNoLongerScanned(BitMask<WiFiBandBitmap> candidates)
+{
+    // Spec: clear the results for the bands scanning has stopped on — which is only the
+    // bands no other request, on this or any other fabric, still asks for.
+    const uint16_t stopped = static_cast<uint16_t>(candidates.Raw() & ~BandsInUse().Raw());
+    if (stopped != 0)
+    {
+        mHardware.ClearCachedResults(BitMask<WiFiBandBitmap>(stopped));
+    }
+}
+
+bool CommissioningProxyBgScanRegistry::LatestDeadline(const FabricState & state, System::Clock::Timestamp & out)
+{
+    System::Clock::Timestamp latest{ 0 };
+    bool any = false;
+    for (const auto & slot : state.requests)
+    {
+        if (!slot.inUse)
+        {
+            continue;
+        }
+        if (!slot.request.hasTimeout)
+        {
+            return false; // one request never expires, so the fabric never does
+        }
+        latest = std::max(latest, slot.request.expiresAt);
+        any    = true;
+    }
+    out = latest;
+    return any;
+}
+
+BitMask<WiFiBandBitmap> CommissioningProxyBgScanRegistry::ReleaseFabric(FabricState & state)
+{
+    uint16_t releasedBands = 0;
+    for (auto & slot : state.requests)
+    {
+        if (slot.inUse)
+        {
+            releasedBands = static_cast<uint16_t>(releasedBands | slot.request.wiFiBands.Raw());
+            slot.inUse    = false;
+        }
+    }
+    CancelLifetime(state);
+    state.inUse = false;
+    return BitMask<WiFiBandBitmap>(releasedBands);
+}
+
+BitMask<WiFiBandBitmap> CommissioningProxyBgScanRegistry::RecomputeFabricLifetime(FabricIndex fabricIndex, FabricState & state)
+{
+    CancelLifetime(state);
+
+    System::Clock::Timestamp deadline{ 0 };
+    if (!LatestDeadline(state, deadline))
+    {
+        return {};
+    }
+
+    state.lifetime.registry    = this;
+    state.lifetime.fabricIndex = fabricIndex;
+    if (mTimerDelegate.StartTimer(&state.lifetime, RemainingUntil(deadline, mTimerDelegate.GetCurrentMonotonicTimestamp())) !=
+        CHIP_NO_ERROR)
+    {
+        // The fabric would otherwise scan unbounded. Nothing here can reject the command
+        // that got us here (it has already been applied), so drop the fabric instead and
+        // let the caller settle the radio and the cached results.
+        ChipLogError(AppServer, "BgScan: could not re-arm lifetime for fabricIndex=%u; dropping it", fabricIndex);
+        return ReleaseFabric(state);
+    }
+    state.lifetime.armed = true;
+    return {};
+}
+
+Status CommissioningProxyBgScanRegistry::Start(FabricIndex fabricIndex, NodeId nodeId, BitMask<CapabilitiesBitmap> transport,
+                                               BitMask<WiFiBandBitmap> wiFiBands, System::Clock::Seconds16 timeout)
+{
+    const bool wasEmpty = !AnyFabricInUse();
+
+    // Reject before any side effect: a fabric may only hold so many concurrent requests,
+    // and a node already holding one is refreshing rather than adding.
+    FabricState * fabric = FindFabric(fabricIndex);
+    if (fabric != nullptr && fabric->Find(nodeId) == nullptr && fabric->FindFree() == nullptr)
+    {
+        ChipLogError(AppServer, "BgScan: fabricIndex=%u already has %u background scans", fabricIndex,
+                     static_cast<unsigned>(fabric->RequestCount()));
+        return Status::ResourceExhausted;
+    }
+    if (fabric == nullptr)
+    {
+        for (auto & candidate : mFabrics)
+        {
+            if (!candidate.inUse)
+            {
+                fabric = &candidate;
+                break;
+            }
+        }
+        VerifyOrReturnError(fabric != nullptr, Status::ResourceExhausted);
+    }
+
+    // Start (or resume) the hardware scan on the first fabric, or whenever we are
+    // paused/deferred. BUSY means the radio is held elsewhere; register the fabric
+    // anyway and stay paused so ResumeIfNeeded() restarts it once it frees up.
+    if (wasEmpty || mPaused)
+    {
+        CHIP_ERROR err = mHardware.StartHardwareScan();
+        if (err == CHIP_ERROR_BUSY)
+        {
+            mPaused = true;
+            ChipLogProgress(AppServer, "BgScan: radio busy, deferring hardware scan (will resume when free)");
+        }
+        else if (err != CHIP_NO_ERROR)
+        {
+            ChipLogError(AppServer, "BgScan: StartHardwareScan failed: %" CHIP_ERROR_FORMAT, err.Format());
+            return Status::Failure;
+        }
+        else
+        {
+            mPaused = false;
+        }
+    }
+
+    // Work out the fabric's deadline as it will be once this request is applied, so the
+    // timer can be armed before anything is committed.
+    const auto now = mTimerDelegate.GetCurrentMonotonicTimestamp();
+    Request incoming;
+    incoming.transport  = transport;
+    incoming.wiFiBands  = wiFiBands;
+    incoming.hasTimeout = (timeout.count() > 0);
+    incoming.expiresAt  = now + timeout;
+
+    bool needTimer                    = incoming.hasTimeout;
+    System::Clock::Timestamp deadline = incoming.expiresAt;
+    if (fabric->inUse)
+    {
+        for (const auto & slot : fabric->requests)
+        {
+            if (!slot.inUse || slot.nodeId == nodeId)
+            {
+                continue; // this node's request is about to be replaced
+            }
+            if (!slot.request.hasTimeout)
+            {
+                needTimer = false;
+                break;
+            }
+            deadline = std::max(deadline, slot.request.expiresAt);
+        }
+    }
+
+    // Arming can fail, so it happens before the table is touched. The old timer is
+    // cancelled only once the new one is running.
+    const bool hadTimer = fabric->inUse && fabric->lifetime.armed;
+    if (needTimer)
+    {
+        // Cancel first: a context can hold only one timer, and this reuses the slot's.
+        if (hadTimer)
+        {
+            mTimerDelegate.CancelTimer(&fabric->lifetime);
+        }
+        fabric->lifetime.registry    = this;
+        fabric->lifetime.fabricIndex = fabricIndex;
+        CHIP_ERROR timerErr          = mTimerDelegate.StartTimer(&fabric->lifetime, RemainingUntil(deadline, now));
+        if (timerErr != CHIP_NO_ERROR)
+        {
+            // Without a lifetime timer the hardware scan would run unbounded, so reject.
+            // Any requests the fabric already held go with it: the old timer was cancelled
+            // just above and cannot be put back, so keeping them would scan unbounded too.
+            // If this call is what started the radio and nothing is left to keep it
+            // running, undo that as well.
+            ChipLogError(AppServer, "BgScan: lifetime StartTimer failed: %" CHIP_ERROR_FORMAT, timerErr.Format());
+            fabric->lifetime.armed                      = false;
+            const BitMask<WiFiBandBitmap> releasedBands = ReleaseFabric(*fabric);
+            if (!AnyFabricInUse())
+            {
+                OnBecameEmpty();
+            }
+            else
+            {
+                ClearBandsNoLongerScanned(releasedBands);
+            }
+            return Status::Failure;
+        }
+        fabric->lifetime.armed = true;
+    }
+    else if (hadTimer)
+    {
+        CancelLifetime(*fabric);
+    }
+
+    // From here nothing can fail: the hardware scan is running and the fabric's new
+    // timer is armed, so the table is safe to modify.
+    //
+    // Each node on a fabric keeps its own request and the fabric scans the union of
+    // them, so this adds a request or replaces only this node's previous one.
+    fabric->inUse       = true;
+    fabric->fabricIndex = fabricIndex;
+    RequestSlot * slot  = fabric->Find(nodeId);
+    if (slot == nullptr)
+    {
+        slot = fabric->FindFree();
+        VerifyOrDie(slot != nullptr); // capacity was checked above
+    }
+    slot->inUse   = true;
+    slot->nodeId  = nodeId;
+    slot->request = incoming;
+
+    return Status::Success;
+}
+
+Status CommissioningProxyBgScanRegistry::Stop(FabricIndex fabricIndex, NodeId nodeId, BitMask<CapabilitiesBitmap> transport,
+                                              BitMask<WiFiBandBitmap> wiFiBands)
+{
+    FabricState * fabric = FindFabric(fabricIndex);
+    VerifyOrReturnValue(fabric != nullptr, Status::NotFound);
+
+    // Spec: if the client's NodeID and FabricID do not match those recorded when the
+    // scan was started, take no action and reject with NOT_FOUND.
+    RequestSlot * slot = fabric->Find(nodeId);
+    VerifyOrReturnValue(slot != nullptr, Status::NotFound);
+
+    const uint8_t reqTransportBits = transport.Raw();
+    const uint16_t reqBandBits     = wiFiBands.Raw();
+    const uint8_t ownTransportBits = slot->request.transport.Raw();
+    const uint16_t ownBandBits     = slot->request.wiFiBands.Raw();
+
+    // A transport bitmap of zero means "stop only the given bands" (spec); otherwise
+    // stop the intersection of the requested and this node's own transports/bands.
+    const uint8_t stopTransportBits = (reqTransportBits == 0) ? 0 : static_cast<uint8_t>(reqTransportBits & ownTransportBits);
+    const uint16_t stopBandBits     = static_cast<uint16_t>(reqBandBits & ownBandBits);
+
+    if (stopTransportBits == 0 && stopBandBits == 0)
+    {
+        // Nothing the caller asked to stop was actually being scanned for this node.
+        return Status::Success;
+    }
+
+    const uint8_t remainTransport = static_cast<uint8_t>(ownTransportBits & ~stopTransportBits);
+    const uint16_t remainBands    = static_cast<uint16_t>(ownBandBits & ~stopBandBits);
+    // A request holds one transport bit (see Start), so the first term decides whenever
+    // the stop names this transport; remainBands only fires on a band-only stop that
+    // clears the last band. A request with no bands at all is not constrained by them.
+    if (remainTransport == 0 || (ownBandBits != 0 && remainBands == 0))
+    {
+        slot->inUse = false;
+    }
+    else
+    {
+        slot->request.transport = BitMask<CapabilitiesBitmap>(remainTransport);
+        slot->request.wiFiBands = BitMask<WiFiBandBitmap>(remainBands);
+    }
+
+    // Dropping a request can shorten the fabric back to a surviving request's deadline,
+    // so the timer is recomputed rather than left where the removed request put it. A
+    // timer that cannot be re-armed releases the fabric, whose bands then go too.
+    uint16_t releasedBands = stopBandBits;
+    if (fabric->RequestCount() == 0)
+    {
+        ReleaseFabric(*fabric);
+    }
+    else
+    {
+        releasedBands = static_cast<uint16_t>(releasedBands | RecomputeFabricLifetime(fabricIndex, *fabric).Raw());
+    }
+
+    // Other requests — on this fabric or another — may still want the radio, so it is
+    // only torn down once nothing is left. Short of that, results for bands nobody
+    // scans any more are still dropped.
+    if (!AnyFabricInUse())
+    {
+        OnBecameEmpty();
+    }
+    else
+    {
+        ClearBandsNoLongerScanned(BitMask<WiFiBandBitmap>(releasedBands));
+    }
+
+    return Status::Success;
+}
+
+void CommissioningProxyBgScanRegistry::RemoveFabric(FabricIndex fabricIndex)
+{
+    FabricState * fabric = FindFabric(fabricIndex);
+    VerifyOrReturn(fabric != nullptr);
+
+    const BitMask<WiFiBandBitmap> removedBands = ReleaseFabric(*fabric);
+
+    ChipLogProgress(AppServer, "BgScan: dropped background scans for removed fabricIndex=%u", fabricIndex);
+
+    if (!AnyFabricInUse())
+    {
+        OnBecameEmpty();
+    }
+    else
+    {
+        ClearBandsNoLongerScanned(removedBands);
+    }
+}
+
+void CommissioningProxyBgScanRegistry::Pause()
+{
+    if (AnyFabricInUse() && !mPaused)
+    {
+        mHardware.StopHardwareScan();
+        mPaused = true;
+        ChipLogProgress(AppServer, "BgScan: paused (radio needed for connect/foreground scan)");
+    }
+}
+
+void CommissioningProxyBgScanRegistry::ResumeIfNeeded()
+{
+    if (!mPaused || !AnyFabricInUse())
+    {
+        return;
+    }
+    CHIP_ERROR err = mHardware.StartHardwareScan();
+    if (err == CHIP_NO_ERROR)
+    {
+        mPaused = false;
+        ChipLogProgress(AppServer, "BgScan: resumed");
+    }
+    else if (err == CHIP_ERROR_BUSY)
+    {
+        // Radio still held; stay paused and retry on the next ResumeIfNeeded().
+    }
+    else
+    {
+        ChipLogError(AppServer, "BgScan: resume failed: %" CHIP_ERROR_FORMAT, err.Format());
+    }
+}
+
+void CommissioningProxyBgScanRegistry::Shutdown()
+{
+    const bool hadFabrics = AnyFabricInUse();
+    for (auto & fabric : mFabrics)
+    {
+        ReleaseFabric(fabric);
+    }
+    if (hadFabrics && !mPaused)
+    {
+        mHardware.StopHardwareScan();
+    }
+    mPaused = false;
+}
+
+void CommissioningProxyBgScanRegistry::OnLifetimeExpiry(FabricIndex fabricIndex)
+{
+    FabricState * fabric = FindFabric(fabricIndex);
+    VerifyOrReturn(fabric != nullptr);
+
+    // The fabric's timer sits at the latest deadline of all its requests, so when it
+    // fires every one of them has expired and the whole fabric goes. The timer has
+    // already been consumed, so just mark it disarmed (do not cancel the one that fired).
+    fabric->lifetime.armed = false;
+
+    // Spec: when the fabric's Timeout elapses its cached results are cleared — but only
+    // for bands no surviving fabric still scans.
+    const BitMask<WiFiBandBitmap> expiringBands = ReleaseFabric(*fabric);
+
+    ChipLogProgress(AppServer, "BgScan: lifetime expired for fabricIndex=%u", fabricIndex);
+
+    if (!AnyFabricInUse())
+    {
+        OnBecameEmpty();
+    }
+    else
+    {
+        ClearBandsNoLongerScanned(expiringBands);
+    }
+}
+
+} // namespace CommissioningProxy
+} // namespace Clusters
+} // namespace app
+} // namespace chip
diff --git a/src/app/clusters/commissioning-proxy-server/CommissioningProxyBgScanRegistry.h b/src/app/clusters/commissioning-proxy-server/CommissioningProxyBgScanRegistry.h
new file mode 100644
index 0000000..d2abc90
--- /dev/null
+++ b/src/app/clusters/commissioning-proxy-server/CommissioningProxyBgScanRegistry.h
@@ -0,0 +1,235 @@
+/*
+ *
+ *    Copyright (c) 2026 Project CHIP Authors
+ *    All rights reserved.
+ *
+ *    Licensed under the Apache License, Version 2.0 (the "License");
+ *    you may not use this file except in compliance with the License.
+ *    You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *    Unless required by applicable law or agreed to in writing, software
+ *    distributed under the License is distributed on an "AS IS" BASIS,
+ *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *    See the License for the specific language governing permissions and
+ *    limitations under the License.
+ */
+
+#pragma once
+
+#include <clusters/CommissioningProxy/Enums.h>
+#include <lib/core/CHIPConfig.h>
+#include <lib/core/CHIPError.h>
+#include <lib/core/DataModelTypes.h>
+#include <lib/support/BitMask.h>
+#include <lib/support/TimerDelegate.h>
+#include <protocols/interaction_model/StatusCode.h>
+#include <system/SystemClock.h>
+
+#include <cstdint>
+
+namespace chip {
+namespace app {
+namespace Clusters {
+namespace CommissioningProxy {
+
+/**
+ * @brief Transport-agnostic background-scan fabric registry.
+ *
+ * Owns everything ProxyBackGroundScanStart/StopRequest needs that does not depend
+ * on the physical transport: the per-fabric scan requests (transport + Wi-Fi bands
+ * + lifetime timer), the spec transport/band overlap arithmetic on Stop, and the
+ * paused/deferred state shared by every transport while the radio is held by a
+ * connect or foreground scan.
+ *
+ * One instance per transport (owned by the transport). The owning transport
+ * supplies the only transport-specific parts via HardwareControl. All calls run on
+ * the Matter thread with the stack lock held.
+ */
+class CommissioningProxyBgScanRegistry
+{
+public:
+    /**
+     * @brief Hooks the owning transport implements. These are the ONLY
+     * transport-specific parts of background scanning.
+     */
+    class HardwareControl
+    {
+    public:
+        virtual ~HardwareControl() = default;
+
+        /**
+         * Start (or resume) the hardware background scan, wiring the transport's own
+         * discovery callback.
+         *   - CHIP_NO_ERROR:   the scan is running.
+         *   - CHIP_ERROR_BUSY: the radio is currently held (BLE: the scanner is owned
+         *                      by a connect or foreground scan; PAF: a ProxyConnect
+         *                      owns the single NAN subscribe slot). The registry keeps
+         *                      the fabrics registered, stays paused, and retries on the
+         *                      next ResumeIfNeeded().
+         *   - other error:     hard failure; the triggering Start() is rejected.
+         */
+        virtual CHIP_ERROR StartHardwareScan() = 0;
+
+        /**
+         * Stop the hardware background scan. Called only when the registry currently
+         * owns the radio (i.e. not while paused).
+         */
+        virtual void StopHardwareScan() = 0;
+
+        /**
+         * Drop cached scan results for this transport
+         * (host->ScanCache().ClearTransport(<this transport>, bands)). @p bands == 0
+         * means the transport stopped entirely, so all of its results go; otherwise
+         * only those bands stopped and only their results go. BLE always receives 0.
+         */
+        virtual void ClearCachedResults(BitMask<WiFiBandBitmap> bands) = 0;
+    };
+
+    CommissioningProxyBgScanRegistry(HardwareControl & hardware, TimerDelegate & timerDelegate) :
+        mHardware(hardware), mTimerDelegate(timerDelegate)
+    {}
+    ~CommissioningProxyBgScanRegistry() { Shutdown(); }
+
+    CommissioningProxyBgScanRegistry(const CommissioningProxyBgScanRegistry &)             = delete;
+    CommissioningProxyBgScanRegistry & operator=(const CommissioningProxyBgScanRegistry &) = delete;
+
+    /**
+     * ProxyBackGroundScanStartRequest. Several nodes on a fabric may scan at once: the
+     * spec identifies each by NodeID + FabricID, so every node keeps its own request and
+     * the fabric scans the union of them. A repeat request from the same node replaces
+     * that node's own request only.
+     *
+     * The fabric holds one lifetime timer, set to the latest deadline of its requests;
+     * a @p timeout of zero means that node never expires, which suppresses the timer
+     * entirely. A rejected Start changes nothing.
+     *
+     * @return RESOURCE_EXHAUSTED once the fabric holds
+     *         CHIP_CONFIG_COMMISSIONING_PROXY_MAX_BGSCAN_REQUESTS_PER_FABRIC requests.
+     *
+     * @p transport SHALL carry only the owning transport's own bit; Stop() relies on it.
+     */
+    Protocols::InteractionModel::Status Start(FabricIndex fabricIndex, NodeId nodeId, BitMask<CapabilitiesBitmap> transport,
+                                              BitMask<WiFiBandBitmap> wiFiBands, System::Clock::Seconds16 timeout);
+
+    /**
+     * ProxyBackGroundScanStopRequest. Narrows or drops the requesting node's own
+     * request: a transport bitmap of zero means "stop only the listed bands". Scanning
+     * only really stops for what no remaining request still covers. SUCCESS is returned
+     * even when nothing overlapped; NOT_FOUND when this node has no request on this
+     * fabric.
+     */
+    Protocols::InteractionModel::Status Stop(FabricIndex fabricIndex, NodeId nodeId, BitMask<CapabilitiesBitmap> transport,
+                                             BitMask<WiFiBandBitmap> wiFiBands);
+
+    /**
+     * Suspend the hardware scan because the radio is needed for a connect or
+     * foreground scan. Idempotent; keeps the fabrics registered.
+     */
+    void Pause();
+
+    /**
+     * Resume a paused hardware scan if fabrics remain and the radio is free. Safe to
+     * call from any "radio freed" path; the transport must wrap this in ScheduleWork
+     * if it could otherwise run re-entrantly.
+     */
+    void ResumeIfNeeded();
+
+    bool IsEmpty() const { return !AnyFabricInUse(); }
+    bool IsPaused() const { return mPaused; }
+
+    /**
+     * Drop every request @p fabricIndex owns, as though it had stopped them all. Used
+     * when the fabric is removed; results for bands nobody scans any more are cleared
+     * and the radio stops once no request is left.
+     */
+    void RemoveFabric(FabricIndex fabricIndex);
+
+    /** Cancel every lifetime timer and stop the hardware scan if the registry owns it. */
+    void Shutdown();
+
+private:
+    // Context for the per-fabric lifetime timer, so the expiry can find its registry
+    // and fabric without a global. Each fabric slot owns one inline, so fabrics with
+    // different lifetimes expire independently with no allocation.
+    struct LifetimeCtx : public TimerContext
+    {
+        CommissioningProxyBgScanRegistry * registry = nullptr;
+        FabricIndex fabricIndex                     = kUndefinedFabricIndex;
+        bool armed                                  = false;
+
+        void TimerFired() override { registry->OnLifetimeExpiry(fabricIndex); }
+    };
+
+    // One ProxyBackGroundScanStartRequest, owned by the node that sent it.
+    struct Request
+    {
+        BitMask<CapabilitiesBitmap> transport;
+        BitMask<WiFiBandBitmap> wiFiBands;
+        System::Clock::Timestamp expiresAt; // only meaningful when hasTimeout
+        bool hasTimeout = false;            // Timeout == 0 means "until an explicit Stop"
+    };
+
+    // One node's request within a fabric. `inUse` false marks a free slot.
+    struct RequestSlot
+    {
+        bool inUse = false;
+        NodeId nodeId;
+        Request request;
+    };
+
+    // Every request from one fabric, plus that fabric's single lifetime timer.
+    struct FabricState
+    {
+        bool inUse = false;
+        FabricIndex fabricIndex;
+        RequestSlot requests[CHIP_CONFIG_COMMISSIONING_PROXY_MAX_BGSCAN_REQUESTS_PER_FABRIC];
+        LifetimeCtx lifetime;
+
+        size_t RequestCount() const;
+        RequestSlot * Find(NodeId nodeId);
+        RequestSlot * FindFree();
+    };
+
+    FabricState * FindFabric(FabricIndex fabricIndex);
+    bool AnyFabricInUse() const;
+
+    /// By value on purpose: the caller passes its own LifetimeCtx member, which this
+    /// deletes. A reference would dangle for the rest of the body.
+    void OnLifetimeExpiry(FabricIndex fabricIndex);
+    void CancelLifetime(FabricState & state);
+    void OnBecameEmpty(); // stop hardware if owned, then clear cache
+
+    /// Drop every request @p state holds along with its lifetime timer, freeing the slot.
+    /// A freed slot must carry no request, or the next fabric to claim it inherits them.
+    /// @return the union of the bands the dropped requests covered, so the caller can
+    ///         settle the cache (OnBecameEmpty / ClearBandsNoLongerScanned).
+    BitMask<WiFiBandBitmap> ReleaseFabric(FabricState & state);
+
+    /// Latest deadline among @p state's requests. Returns false when some request has no
+    /// timeout, in which case the fabric must not hold a timer at all.
+    static bool LatestDeadline(const FabricState & state, System::Clock::Timestamp & out);
+
+    /// Re-arm (or drop) @p fabricIndex's timer after its request set changed — a Stop can
+    /// shorten the fabric back to a surviving request's deadline. A timer that cannot be
+    /// armed releases the fabric rather than leaving it scanning unbounded.
+    /// @return the bands the released requests covered, or none if the timer was armed.
+    BitMask<WiFiBandBitmap> RecomputeFabricLifetime(FabricIndex fabricIndex, FabricState & state);
+
+    /// Union of the bands every remaining request wants, across all fabrics.
+    BitMask<WiFiBandBitmap> BandsInUse() const;
+
+    /// Of @p candidates, drop the cached results for those no request still covers.
+    void ClearBandsNoLongerScanned(BitMask<WiFiBandBitmap> candidates);
+
+    FabricState mFabrics[CHIP_CONFIG_MAX_FABRICS];
+    bool mPaused = false;
+    HardwareControl & mHardware;
+    TimerDelegate & mTimerDelegate;
+};
+
+} // namespace CommissioningProxy
+} // namespace Clusters
+} // namespace app
+} // namespace chip
diff --git a/src/app/clusters/commissioning-proxy-server/CommissioningProxyCluster.cpp b/src/app/clusters/commissioning-proxy-server/CommissioningProxyCluster.cpp
new file mode 100644
index 0000000..6854bdf
--- /dev/null
+++ b/src/app/clusters/commissioning-proxy-server/CommissioningProxyCluster.cpp
@@ -0,0 +1,748 @@
+/*
+ *
+ *    Copyright (c) 2026 Project CHIP Authors
+ *    All rights reserved.
+ *
+ *    Licensed under the Apache License, Version 2.0 (the "License");
+ *    you may not use this file except in compliance with the License.
+ *    You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *    Unless required by applicable law or agreed to in writing, software
+ *    distributed under the License is distributed on an "AS IS" BASIS,
+ *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *    See the License for the specific language governing permissions and
+ *    limitations under the License.
+ */
+
+#include <app/clusters/commissioning-proxy-server/CommissioningProxyCluster.h>
+
+#include <app/data-model/Decode.h>
+#include <app/data-model/Encode.h>
+#include <app/server-cluster/AttributeListBuilder.h>
+#include <clusters/CommissioningProxy/Attributes.h>
+#include <clusters/CommissioningProxy/Commands.h>
+#include <clusters/CommissioningProxy/Metadata.h>
+#include <clusters/CommissioningProxy/Structs.h>
+#include <lib/support/logging/CHIPLogging.h>
+#include <messaging/ExchangeContext.h>
+#include <platform/CommissionableDataProvider.h> // for kMaxDiscriminatorValue
+#include <protocols/interaction_model/StatusCode.h>
+#include <system/SystemClock.h>
+#include <system/SystemPacketBuffer.h>
+
+using chip::Protocols::InteractionModel::Status;
+
+namespace chip {
+namespace app {
+namespace Clusters {
+namespace CommissioningProxy {
+
+using namespace CommissioningProxy::Attributes;
+
+template <typename T>
+constexpr bool HasExactlyOneBitSet(T v)
+{
+    static_assert(std::is_integral_v<T>, "Integral type required");
+    return v != 0 && (v & (v - 1)) == 0;
+}
+
+// Masks of all spec-defined bits for each bitmap type.
+// Any bit outside these masks is a reserved bit and SHALL be rejected with InvalidCommand.
+constexpr uint8_t kValidTransportBits = static_cast<uint8_t>(CapabilitiesBitmap::kBle) |
+    static_cast<uint8_t>(CapabilitiesBitmap::kWiFiPAF) | static_cast<uint8_t>(CapabilitiesBitmap::kNtl);
+constexpr uint16_t kValidWiFiBandBits = static_cast<uint16_t>(WiFiBandBitmap::k2g4) | static_cast<uint16_t>(WiFiBandBitmap::k5g);
+
+CHIP_ERROR CommissioningProxyCluster::Startup(ServerClusterContext & context)
+{
+    ReturnErrorOnFailure(DefaultServerCluster::Startup(context));
+
+    if (mFabricTable != nullptr)
+    {
+        ReturnErrorOnFailure(mFabricTable->AddFabricDelegate(this));
+    }
+    return CHIP_NO_ERROR;
+}
+
+void CommissioningProxyCluster::OnFabricRemoved(const FabricTable & /*fabricTable*/, FabricIndex fabricIndex)
+{
+    ChipLogProgress(Zcl, "CommissioningProxy: fabricIndex=%u removed; dropping its proxy state", fabricIndex);
+
+    // An in-flight ProxyConnectRequest has no session yet, so cancel it separately.
+    (void) CancelPendingConnect(fabricIndex);
+
+    // Drain the fabric's sessions, telling the owning transport to drop each link.
+    while (auto sessionId = mSessions.FindAnySessionIdOnFabric(fabricIndex))
+    {
+        if (auto info = mSessions.FindSession(*sessionId))
+        {
+            if (CommissioningProxyTransport * transport = FindTransport(info->transport))
+            {
+                (void) transport->Disconnect(*sessionId);
+            }
+        }
+        mSessions.RemoveSession(*sessionId);
+    }
+
+    // Background scans live in each driver's own registry.
+    for (size_t i = 0; i < mTransportCount; i++)
+    {
+        mTransports[i]->OnFabricRemoved(fabricIndex);
+    }
+}
+
+DataModel::ActionReturnStatus CommissioningProxyCluster::WriteAttribute(const DataModel::WriteAttributeRequest & request,
+                                                                        AttributeValueDecoder & decoder)
+{
+    // These writable attributes are stored by the cluster so that change-reporting is
+    // always the cluster's responsibility. SetAttributeValue reports only on an actual
+    // value change.
+    switch (request.path.mAttributeId)
+    {
+    case ScanMaxTime::Id: {
+        uint8_t time;
+        ReturnErrorOnFailure(decoder.Decode(time));
+        // Spec: ScanMaxTime has constraint "min 1"; reject 0 with ConstraintError.
+        VerifyOrReturnError(time >= 1, Status::ConstraintError);
+        SetAttributeValue(mScanMaxTime, time, ScanMaxTime::Id);
+        return Status::Success;
+    }
+    case CacheTimeout::Id: {
+        uint16_t cacheTimeout;
+        ReturnErrorOnFailure(decoder.Decode(cacheTimeout));
+        // Spec: CacheTimeout has constraint "min 1"; reject 0 with ConstraintError.
+        VerifyOrReturnError(cacheTimeout >= 1, Status::ConstraintError);
+        SetAttributeValue(mCacheTimeout, cacheTimeout, CacheTimeout::Id);
+        return Status::Success;
+    }
+    default:
+        return Protocols::InteractionModel::Status::UnsupportedWrite;
+    }
+}
+
+DataModel::ActionReturnStatus CommissioningProxyCluster::ReadAttribute(const DataModel::ReadAttributeRequest & request,
+                                                                       AttributeValueEncoder & encoder)
+{
+    switch (request.path.mAttributeId)
+    {
+    case Globals::Attributes::ClusterRevision::Id:
+        // Per spec ClusterRevision SHALL be the highest revision number in the
+        // Revision History table; sourced from the cluster's generated Metadata
+        // (CommissioningProxy::kRevision in Metadata.h).
+        return encoder.Encode(kRevision);
+
+    case FeatureMap::Id:
+        return encoder.Encode(mFeatureFlags);
+
+    case Transport::Id:
+        return encoder.Encode(GetSupportedTransports());
+
+    case MaxSessions::Id:
+        return encoder.Encode(mMaxSessions);
+
+    case ScanMaxTime::Id:
+        return encoder.Encode(mScanMaxTime);
+
+    case MaxCachedResults::Id:
+        return encoder.Encode(mMaxCachedResults);
+
+    case NumCachedResults::Id:
+        return encoder.Encode(mScanCache.Count());
+
+    case CacheTimeout::Id:
+        return encoder.Encode(mCacheTimeout);
+
+    case CachedResults::Id:
+        return mScanCache.Encode(encoder);
+
+    case WiFiBand::Id:
+        return encoder.Encode(mSupportedWiFiBands);
+
+    default:
+        return Status::UnsupportedAttribute;
+    }
+}
+
+chip::BitMask<CapabilitiesBitmap> CommissioningProxyCluster::GetSupportedTransports() const
+{
+    // A transport is supported iff a driver is registered for it.
+    chip::BitMask<CapabilitiesBitmap> supported;
+    for (size_t i = 0; i < mTransportCount; i++)
+    {
+        supported.Set(mTransports[i]->GetTransportType());
+    }
+    return supported;
+}
+
+CommissioningProxyTransport * CommissioningProxyCluster::FindTransport(CapabilitiesBitmap bit) const
+{
+    for (size_t i = 0; i < mTransportCount; i++)
+    {
+        if (mTransports[i]->GetTransportType() == bit)
+        {
+            return mTransports[i];
+        }
+    }
+    return nullptr;
+}
+
+std::optional<DataModel::ActionReturnStatus> CommissioningProxyCluster::InvokeCommand(const DataModel::InvokeRequest & request,
+                                                                                      TLV::TLVReader & input_arguments,
+                                                                                      CommandHandler * handler)
+{
+    using namespace Commands;
+
+    // Each handler returns its own std::optional result: std::nullopt when the
+    // delegate has taken ownership of the response (sync or async AddResponse) so
+    // the framework must not add a duplicate status, or a concrete status otherwise.
+    switch (request.path.mCommandId)
+    {
+    case ProxyConnectRequest::Id:
+        return HandleProxyConnectRequest(request, input_arguments, handler);
+
+    case ProxyDisconnectRequest::Id:
+        return HandleProxyDisconnectRequest(request, input_arguments, handler);
+
+    case ProxyScanRequest::Id:
+        return HandleProxyScanRequest(request, input_arguments, handler);
+
+    case ProxyBackGroundScanStartRequest::Id:
+        return HandleProxyBackGroundScanStartRequest(request, input_arguments, handler);
+
+    case ProxyBackGroundScanStopRequest::Id:
+        return HandleProxyBackGroundScanStopRequest(request, input_arguments, handler);
+
+    case ProxyMessageRequest::Id:
+        return HandleProxyMessageRequest(request, input_arguments, handler);
+
+    default:
+        return Status::UnsupportedCommand;
+    }
+}
+
+std::optional<DataModel::ActionReturnStatus>
+CommissioningProxyCluster::HandleProxyConnectRequest(const DataModel::InvokeRequest & request, TLV::TLVReader & input_arguments,
+                                                     CommandHandler * handler)
+{
+    Commands::ProxyConnectRequest::DecodableType commandData;
+    ReturnErrorOnFailure(commandData.Decode(input_arguments, request.GetAccessingFabricIndex()));
+
+    // Only a single transport SHALL be selected per spec
+    VerifyOrReturnError(HasExactlyOneBitSet(commandData.transport.Raw()), Status::InvalidCommand);
+
+    // Transport must not contain reserved (undefined) bits. Checked before the supported
+    // set below so a reserved bit is rejected the same way here as it is on the scan
+    // commands, rather than being reported as an unsupported transport.
+    VerifyOrReturnError((commandData.transport.Raw() & ~kValidTransportBits) == 0, Status::InvalidCommand);
+
+    // Spec: Discriminator field constraint is "0 to 4095" (12-bit); any other
+    // field being invalid SHALL return InvalidCommand. Matches the validation in
+    // AdministratorCommissioning / JointFabricAdministrator.
+    VerifyOrReturnError(commandData.discriminator <= kMaxDiscriminatorValue, Status::InvalidCommand);
+
+    // The selected transport must be supported by this proxy instance.
+    if (!GetSupportedTransports().HasAny(commandData.transport))
+    {
+        ChipLogError(Zcl, "Commissioning Proxy: requested transport not supported by this instance");
+        return Status::InvalidTransportType;
+    }
+
+    // WiFiBand is meaningful only with the WiFiPAF transport per spec; if the
+    // selected transport is not kWiFiPAF, the field must be absent.
+    if (commandData.wiFiBand.HasValue() && commandData.transport != CapabilitiesBitmap::kWiFiPAF)
+    {
+        ChipLogError(Zcl, "Commissioning Proxy: WiFiBand provided with non-WiFiPAF transport");
+        return Status::InvalidCommand;
+    }
+
+    // WiFiBand is only valid when the WI feature is enabled per spec.
+    if (commandData.wiFiBand.HasValue())
+    {
+        VerifyOrReturnError(mFeatureFlags.Has(Feature::kWiFiNetworkInterface), Status::InvalidCommand);
+
+        // WiFiBand must not contain reserved bits.
+        VerifyOrReturnError((commandData.wiFiBand.Value().Raw() & ~kValidWiFiBandBits) == 0, Status::InvalidCommand);
+
+        // WiFiBand must be a subset of the bands supported by this proxy.
+        if ((commandData.wiFiBand.Value().Raw() & ~mSupportedWiFiBands.Raw()) != 0)
+        {
+            ChipLogError(Zcl, "CommissioningProxy: Requested WiFiBand not in supported bands");
+            return Status::InvalidTransportType;
+        }
+    }
+
+    // Spec: "If MaxSessions are in use, a RESOURCE_EXHAUSTED status SHALL be
+    // returned."  Enforced here so the check covers established sessions and any
+    // in-flight connect across all transports (GetActiveSessionCount()).
+    if (GetActiveSessionCount() >= mMaxSessions)
+    {
+        ChipLogError(Zcl, "Commissioning Proxy: MaxSessions reached (%u/%u)", GetActiveSessionCount(), mMaxSessions);
+        return Status::ResourceExhausted;
+    }
+
+    // The transport driver establishes the connection, allocates/registers a session
+    // via Sessions(), and calls handler->AddResponse() with the ProxyConnectResponse
+    // (sync or async). The state transition to kState_CPConnected happens in the
+    // driver's success path once the transport is actually up.
+    CommissioningProxyTransport * transport = FindTransport(static_cast<CapabilitiesBitmap>(commandData.transport.Raw()));
+    VerifyOrReturnValue(transport != nullptr, Status::InvalidTransportType);
+
+    auto status = transport->Connect(handler, request, commandData.discriminator, System::Clock::Seconds16(commandData.timeout));
+
+    // On error, surface the status; on success the driver owns the response, so
+    // return nullopt to avoid a duplicate framework status.
+    VerifyOrReturnValue(status == Status::Success, DataModel::ActionReturnStatus(status));
+    return std::nullopt;
+}
+
+std::optional<DataModel::ActionReturnStatus>
+CommissioningProxyCluster::HandleProxyDisconnectRequest(const DataModel::InvokeRequest & request, TLV::TLVReader & input_arguments,
+                                                        CommandHandler * handler)
+{
+    Commands::ProxyDisconnectRequest::DecodableType commandData;
+    ReturnErrorOnFailure(commandData.Decode(input_arguments, request.GetAccessingFabricIndex()));
+
+    // A null SessionID SHALL cancel any ongoing ProxyConnectRequest for the invoking fabric, not disconnect a session.
+    if (commandData.sessionID.IsNull())
+    {
+        ChipLogProgress(Zcl, "HandleProxyDisconnectRequest: sessionID=null (cancel pending connect)");
+        return CancelPendingConnect(request.subjectDescriptor.fabricIndex);
+    }
+
+    const uint16_t sessionId = commandData.sessionID.Value();
+    ChipLogProgress(Zcl, "HandleProxyDisconnectRequest: sessionID=0x%04x", sessionId);
+
+    auto info = mSessions.FindSession(sessionId);
+    VerifyOrReturnValue(info.has_value(), Status::NotFound);
+    // Per spec, a session may only be disconnected by the fabric that owns it.
+    VerifyOrReturnValue(request.subjectDescriptor.fabricIndex == info->fabricIndex, Status::NotFound);
+
+    const CapabilitiesBitmap sessTransport = info->transport;
+    if (CommissioningProxyTransport * transport = FindTransport(sessTransport))
+    {
+        auto s = transport->Disconnect(sessionId);
+        // On failure, leave the session intact and surface the error.
+        VerifyOrReturnValue(s == Status::Success, DataModel::ActionReturnStatus(s));
+    }
+
+    // Drop the session record (and any pending ProxyMessage, without answering it).
+    mSessions.RemoveSession(sessionId);
+
+    // With MaxSessions > 1 several sessions may be open at once. Only transition the
+    // cluster back to disconnected, and notify transports, once the last session is
+    // gone; otherwise the proxy would report disconnected while sessions are active.
+    if (GetActiveSessionCount() == 0)
+    {
+        for (size_t i = 0; i < mTransportCount; i++)
+        {
+            mTransports[i]->OnAllSessionsClosed();
+        }
+
+        CHIP_ERROR stateErr = SetCPState(kState_CPDisconnected);
+        if (stateErr != CHIP_NO_ERROR)
+        {
+            ChipLogError(Zcl, "HandleProxyDisconnectRequest: SetCPState failed: %" CHIP_ERROR_FORMAT, stateErr.Format());
+        }
+    }
+
+    return Status::Success;
+}
+
+std::optional<DataModel::ActionReturnStatus>
+CommissioningProxyCluster::HandleProxyScanRequest(const DataModel::InvokeRequest & request, TLV::TLVReader & input_arguments,
+                                                  CommandHandler * handler)
+{
+    Commands::ProxyScanRequest::DecodableType commandData;
+    ReturnErrorOnFailure(DataModel::Decode(input_arguments, commandData));
+
+    // Transport must be non-zero — at least one transport must be selected.
+    VerifyOrReturnError(commandData.transport.Raw() != 0, Status::InvalidCommand);
+
+    // Transport must not contain reserved (undefined) bits.
+    VerifyOrReturnError((commandData.transport.Raw() & ~kValidTransportBits) == 0, Status::InvalidCommand);
+
+    // Each selected transport must be supported by this proxy instance.
+    // The supported set is derived from enabled feature flags (same as the Transport attribute).
+    {
+        auto supported = GetSupportedTransports();
+        if ((commandData.transport.Raw() & ~supported.Raw()) != 0)
+        {
+            ChipLogError(Zcl, "CommissioningProxy: one or more requested transports not supported by this instance");
+            return Status::InvalidTransportType;
+        }
+    }
+
+    if (commandData.wiFiBands.HasValue())
+    {
+        // WiFiBands field is only valid when the WI feature is enabled.
+        VerifyOrReturnError(mFeatureFlags.Has(Feature::kWiFiNetworkInterface), Status::InvalidCommand);
+
+        // WiFiBands must not contain reserved bits.
+        VerifyOrReturnError((commandData.wiFiBands.Value().Raw() & ~kValidWiFiBandBits) == 0, Status::InvalidCommand);
+
+        // WiFiBands must be a subset of the bands supported by this proxy.
+        if ((commandData.wiFiBands.Value().Raw() & ~mSupportedWiFiBands.Raw()) != 0)
+        {
+            ChipLogError(Zcl, "CommissioningProxy: Requested WiFiBand not in supported bands");
+            return Status::InvalidTransportType;
+        }
+    }
+
+    // A ProxyScanRequest MAY select multiple transports: scan each requested,
+    // registered transport in parallel and aggregate into one ProxyScanResponse.
+    if (mScanAggregator.InProgress())
+    {
+        return Status::Busy;
+    }
+
+    const System::Clock::Seconds16 scanMaxTime{ mScanMaxTime };
+    if (CHIP_ERROR err = mScanAggregator.Begin(handler, request.path, scanMaxTime); err != CHIP_NO_ERROR)
+    {
+        ChipLogError(Zcl, "CommissioningProxy: could not begin scan aggregation: %" CHIP_ERROR_FORMAT, err.Format());
+        return Status::Failure;
+    }
+
+    // First non-success from a sub-scan that could not start; only returned if no
+    // sub-scan starts at all.
+    auto firstError = Status::Success;
+    for (size_t i = 0; i < mTransportCount; i++)
+    {
+        CommissioningProxyTransport * transport = mTransports[i];
+        if (!commandData.transport.Has(transport->GetTransportType()))
+        {
+            continue;
+        }
+        auto s = transport->Scan(scanMaxTime);
+        if (s == Status::Success)
+        {
+            mScanAggregator.AddPendingContributor();
+        }
+        else if (firstError == Status::Success)
+        {
+            firstError = s;
+        }
+    }
+
+    if (mScanAggregator.PendingContributors() == 0)
+    {
+        ChipLogError(Zcl, "CommissioningProxy: no requested transport scan could be started");
+        mScanAggregator.Abort();
+        return (firstError == Status::Success) ? DataModel::ActionReturnStatus(Status::Failure)
+                                               : DataModel::ActionReturnStatus(firstError);
+    }
+
+    // The aggregator owns the exchange now; keep it alive for the full scan window.
+    handler->FlushAcksRightAwayOnSlowCommand();
+    if (auto * exchange = handler->GetExchangeContext())
+    {
+        exchange->SetResponseTimeout(scanMaxTime + System::Clock::Seconds16(5));
+    }
+
+    // If every started sub-scan already reported synchronously, emit now.
+    mScanAggregator.MaybeEmitIfComplete();
+
+    return std::nullopt;
+}
+
+std::optional<DataModel::ActionReturnStatus>
+CommissioningProxyCluster::HandleProxyBackGroundScanStartRequest(const DataModel::InvokeRequest & request,
+                                                                 TLV::TLVReader & input_arguments, CommandHandler * handler)
+{
+    Commands::ProxyBackGroundScanStartRequest::DecodableType commandData;
+    ReturnErrorOnFailure(DataModel::Decode(input_arguments, commandData));
+
+    // Transport must be non-zero — at least one transport must be selected.
+    VerifyOrReturnError(commandData.transport.Raw() != 0, Status::InvalidCommand);
+
+    // Transport must not contain reserved (undefined) bits.
+    VerifyOrReturnError((commandData.transport.Raw() & ~kValidTransportBits) == 0, Status::InvalidCommand);
+
+    // Each selected transport must be supported by this proxy instance.
+    // The supported set is derived from enabled feature flags (same as the Transport attribute).
+    {
+        auto supported = GetSupportedTransports();
+        if ((commandData.transport.Raw() & ~supported.Raw()) != 0)
+        {
+            ChipLogError(Zcl, "CommissioningProxy: one or more requested transports not supported by this instance");
+            return Status::InvalidTransportType;
+        }
+    }
+
+    chip::BitMask<WiFiBandBitmap> wiFiBands;
+    if (commandData.wiFiBands.HasValue())
+    {
+        // WiFiBands field is only valid when the WI feature is enabled.
+        VerifyOrReturnError(mFeatureFlags.Has(Feature::kWiFiNetworkInterface), Status::InvalidCommand);
+
+        // WiFiBands must not contain reserved bits.
+        VerifyOrReturnError((commandData.wiFiBands.Value().Raw() & ~kValidWiFiBandBits) == 0, Status::InvalidCommand);
+
+        // WiFiBands must be a subset of the bands supported by this proxy.
+        if ((commandData.wiFiBands.Value().Raw() & ~mSupportedWiFiBands.Raw()) != 0)
+        {
+            ChipLogError(Zcl, "CommissioningProxy: Requested WiFiBand not in supported bands");
+            return Status::InvalidTransportType;
+        }
+
+        wiFiBands = commandData.wiFiBands.Value();
+    }
+
+    const FabricIndex fabricIndex = request.subjectDescriptor.fabricIndex;
+    const NodeId nodeId           = request.subjectDescriptor.subject;
+
+    // A background scan MAY select multiple transports. Either all of them start, or
+    // the ones that did are stopped again, so a failed command leaves nothing running.
+    auto result = Status::Success;
+    chip::BitMask<CapabilitiesBitmap> started;
+    for (size_t i = 0; i < mTransportCount; i++)
+    {
+        CommissioningProxyTransport * transport = mTransports[i];
+        if (!commandData.transport.Has(transport->GetTransportType()))
+        {
+            continue;
+        }
+        result = transport->BgScanStart(System::Clock::Seconds16(commandData.timeout), wiFiBands, fabricIndex, nodeId);
+        if (result != Status::Success)
+        {
+            break;
+        }
+        started.Set(transport->GetTransportType());
+    }
+
+    if (result != Status::Success && started.Raw() != 0)
+    {
+        ChipLogError(Zcl, "CommissioningProxy: background scan start failed; rolling back started transport(s)");
+        for (size_t i = 0; i < mTransportCount; i++)
+        {
+            CommissioningProxyTransport * transport = mTransports[i];
+            if (started.Has(transport->GetTransportType()))
+            {
+                auto stopStatus = transport->BgScanStop(chip::BitMask<CapabilitiesBitmap>(transport->GetTransportType()), wiFiBands,
+                                                        fabricIndex, nodeId);
+                if (stopStatus != Status::Success)
+                {
+                    // The scan we just started is still running and its cached results
+                    // were not cleared; the commissioner has been told nothing started.
+                    ChipLogError(Zcl, "CommissioningProxy: rollback of transport 0x%x failed with status 0x%02x",
+                                 chip::to_underlying(transport->GetTransportType()), chip::to_underlying(stopStatus));
+                }
+            }
+        }
+    }
+
+    return result;
+}
+
+std::optional<DataModel::ActionReturnStatus>
+CommissioningProxyCluster::HandleProxyBackGroundScanStopRequest(const DataModel::InvokeRequest & request,
+                                                                TLV::TLVReader & input_arguments, CommandHandler * handler)
+{
+    Commands::ProxyBackGroundScanStopRequest::DecodableType commandData;
+    ReturnErrorOnFailure(DataModel::Decode(input_arguments, commandData));
+
+    // transport=0 and no wiFiBands → nothing to stop
+    VerifyOrReturnError(commandData.transport.Raw() != 0 || commandData.wiFiBands.HasValue(), Status::InvalidCommand);
+
+    // Reserved bits in transport must be clear
+    VerifyOrReturnError((commandData.transport.Raw() & ~kValidTransportBits) == 0, Status::InvalidCommand);
+
+    // Each non-zero transport bit must be in the supported set.
+    // transport=0 (band-only stop) skips this check — there are no unsupported bits in zero.
+    {
+        auto supported = GetSupportedTransports();
+        if ((commandData.transport.Raw() & ~supported.Raw()) != 0)
+        {
+            ChipLogError(Zcl, "CommissioningProxy: one or more requested transports not supported by this instance");
+            return Status::InvalidTransportType;
+        }
+    }
+
+    chip::BitMask<WiFiBandBitmap> wiFiBands;
+    if (commandData.wiFiBands.HasValue())
+    {
+        // WiFiBands field is only valid when the WI feature is enabled.
+        VerifyOrReturnError(mFeatureFlags.Has(Feature::kWiFiNetworkInterface), Status::InvalidCommand);
+
+        // Reserved bits must be clear.
+        VerifyOrReturnError((commandData.wiFiBands.Value().Raw() & ~kValidWiFiBandBits) == 0, Status::InvalidCommand);
+
+        wiFiBands = commandData.wiFiBands.Value();
+    }
+
+    const FabricIndex fabricIndex = request.subjectDescriptor.fabricIndex;
+    const NodeId nodeId           = request.subjectDescriptor.subject;
+
+    // Fan the stop out to every registered transport with the full request mask;
+    // each matches it against its own per-fabric record (a driver may partially
+    // stop, e.g. one band of several) and returns NotFound if nothing matched.
+    // NotFound is returned to the commissioner only if no transport matched at all.
+    bool matched = false;
+    auto err     = Status::Success;
+    for (size_t i = 0; i < mTransportCount; i++)
+    {
+        auto s = mTransports[i]->BgScanStop(commandData.transport, wiFiBands, fabricIndex, nodeId);
+        if (s != Status::NotFound)
+        {
+            matched = true;
+            if (s != Status::Success)
+            {
+                err = s;
+            }
+        }
+    }
+
+    return matched ? err : Status::NotFound;
+}
+
+std::optional<DataModel::ActionReturnStatus>
+CommissioningProxyCluster::HandleProxyMessageRequest(const DataModel::InvokeRequest & request, TLV::TLVReader & input_arguments,
+                                                     CommandHandler * handler)
+{
+    Commands::ProxyMessageRequest::DecodableType commandData;
+    ReturnErrorOnFailure(commandData.Decode(input_arguments, request.GetAccessingFabricIndex()));
+
+    const uint16_t sessionId = commandData.sessionID;
+
+    auto info = mSessions.FindSession(sessionId);
+    VerifyOrReturnValue(info.has_value(), Status::NotFound);
+    // A session's messages may only be forwarded by the fabric that owns it.
+    VerifyOrReturnValue(request.subjectDescriptor.fabricIndex == info->fabricIndex, Status::NotFound);
+
+    // Per spec: a null/empty message is the Commissioner polling for a queued
+    // response. Answer immediately with a null message.
+    if (commandData.message.IsNull() || commandData.message.Value().empty())
+    {
+        Commands::ProxyMessageResponse::Type pollResponse;
+        pollResponse.sessionID = sessionId;
+        pollResponse.message.SetNull();
+        handler->AddResponse(request.path, pollResponse);
+        return std::nullopt;
+    }
+
+    CommissioningProxyTransport * transport = FindTransport(info->transport);
+    VerifyOrReturnValue(transport != nullptr, Status::Failure);
+
+    System::PacketBufferHandle buf =
+        System::PacketBufferHandle::NewWithData(commandData.message.Value().data(), commandData.message.Value().size());
+    VerifyOrReturnValue(!buf.IsNull(), Status::Failure);
+
+    // Per spec ResponseTimeout=0: forward best-effort and answer immediately.
+    if (commandData.responseTimeout == 0)
+    {
+        CHIP_ERROR sendErr = transport->SendMessage(sessionId, std::move(buf));
+        if (sendErr != CHIP_NO_ERROR)
+        {
+            ChipLogDetail(Zcl, "ProxyMessageRequest(ResponseTimeout=0): SendMessage failed: %" CHIP_ERROR_FORMAT, sendErr.Format());
+        }
+        Commands::ProxyMessageResponse::Type immediate;
+        immediate.sessionID = sessionId;
+        immediate.message.SetNull();
+        handler->AddResponse(request.path, immediate);
+        return std::nullopt;
+    }
+
+    // Keep the exchange open until the commissionee replies (or the timeout fires).
+    auto begin = mSessions.BeginMessage(sessionId, handler, request, commandData.responseTimeout);
+    VerifyOrReturnValue(begin == Status::Success, DataModel::ActionReturnStatus(begin));
+
+    CHIP_ERROR err = transport->SendMessage(sessionId, std::move(buf));
+    if (err != CHIP_NO_ERROR)
+    {
+        ChipLogError(Zcl, "ProxyMessageRequest: SendMessage failed: %" CHIP_ERROR_FORMAT, err.Format());
+        mSessions.AbortPending(sessionId);
+        return DataModel::ActionReturnStatus(Status::Failure);
+    }
+
+    // Reply is delivered asynchronously via Sessions().DispatchMessageResponse().
+    return std::nullopt;
+}
+
+Protocols::InteractionModel::Status CommissioningProxyCluster::CancelPendingConnect(FabricIndex fabricIndex)
+{
+    // A null-SessionID ProxyDisconnectRequest cancels the invoking fabric's own
+    // pending connect(s). Every transport is offered the cancel (no early return):
+    // the spec says null cancels *any* ongoing connect for the fabric, and a foreign
+    // connect on one transport must not mask this fabric's connect on another.
+    //   Success        — a connect owned by this fabric was cancelled
+    //   NotFound       — a connect is pending but owned by a different fabric
+    //   InvalidInState — no connect pending on that transport
+    bool cancelledOwn      = false;
+    bool sawForeignPending = false;
+    for (size_t i = 0; i < mTransportCount; i++)
+    {
+        auto s = mTransports[i]->CancelPendingConnect(fabricIndex);
+        if (s == Status::Success)
+        {
+            cancelledOwn = true;
+        }
+        else if (s == Status::NotFound)
+        {
+            sawForeignPending = true;
+        }
+    }
+    if (cancelledOwn)
+    {
+        return Status::Success;
+    }
+    return sawForeignPending ? Status::NotFound : Status::InvalidInState;
+}
+
+CHIP_ERROR CommissioningProxyCluster::Attributes(const ConcreteClusterPath & path,
+                                                 ReadOnlyBufferBuilder<DataModel::AttributeEntry> & builder)
+{
+    static constexpr DataModel::AttributeEntry optionalAttributes[] = {
+        MaxCachedResults::kMetadataEntry, NumCachedResults::kMetadataEntry, CacheTimeout::kMetadataEntry,
+        CachedResults::kMetadataEntry,    WiFiBand::kMetadataEntry,
+    };
+
+    AttributeListBuilder listBuilder(builder);
+
+    return listBuilder.Append(Span(kMandatoryMetadata), Span(optionalAttributes), mEnabledOptionalAttributes);
+}
+
+CHIP_ERROR CommissioningProxyCluster::AcceptedCommands(const ConcreteClusterPath & path,
+                                                       ReadOnlyBufferBuilder<DataModel::AcceptedCommandEntry> & builder)
+{
+    ReturnErrorOnFailure(
+        builder.AppendElements({ Commands::ProxyConnectRequest::kMetadataEntry, Commands::ProxyDisconnectRequest::kMetadataEntry,
+                                 Commands::ProxyScanRequest::kMetadataEntry, Commands::ProxyMessageRequest::kMetadataEntry }));
+
+    if (mFeatureFlags.Has(Feature::kBackgroundScan))
+    {
+        ReturnErrorOnFailure(builder.AppendElements({
+            Commands::ProxyBackGroundScanStartRequest::kMetadataEntry,
+            Commands::ProxyBackGroundScanStopRequest::kMetadataEntry,
+        }));
+    }
+    return CHIP_NO_ERROR;
+}
+
+CHIP_ERROR CommissioningProxyCluster::GeneratedCommands(const ConcreteClusterPath & path,
+                                                        ReadOnlyBufferBuilder<CommandId> & builder)
+{
+    // The three response commands the server can generate. All are mandatory and
+    // independent of the feature flags (the BackgroundScan commands respond with a
+    // plain status, not a generated command).
+    return builder.AppendElements({
+        Commands::ProxyConnectResponse::Id,
+        Commands::ProxyScanResponse::Id,
+        Commands::ProxyMessageResponse::Id,
+    });
+}
+
+CHIP_ERROR CommissioningProxyCluster::SetCPState(CommissioningProxyCluster::State state)
+{
+    mMainCommissioningProxyState = state;
+    return CHIP_NO_ERROR;
+}
+
+CommissioningProxyCluster::State CommissioningProxyCluster::GetCPState()
+{
+    return mMainCommissioningProxyState;
+}
+
+} // namespace CommissioningProxy
+} // namespace Clusters
+} // namespace app
+} // namespace chip
diff --git a/src/app/clusters/commissioning-proxy-server/CommissioningProxyCluster.h b/src/app/clusters/commissioning-proxy-server/CommissioningProxyCluster.h
new file mode 100644
index 0000000..59c5c2a
--- /dev/null
+++ b/src/app/clusters/commissioning-proxy-server/CommissioningProxyCluster.h
@@ -0,0 +1,287 @@
+/*
+ *
+ *    Copyright (c) 2026 Project CHIP Authors
+ *    All rights reserved.
+ *
+ *    Licensed under the Apache License, Version 2.0 (the "License");
+ *    you may not use this file except in compliance with the License.
+ *    You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *    Unless required by applicable law or agreed to in writing, software
+ *    distributed under the License is distributed on an "AS IS" BASIS,
+ *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *    See the License for the specific language governing permissions and
+ *    limitations under the License.
+ */
+
+#pragma once
+
+#include <app/clusters/commissioning-proxy-server/CommissioningProxyScanAggregator.h>
+#include <app/clusters/commissioning-proxy-server/CommissioningProxyScanCache.h>
+#include <app/clusters/commissioning-proxy-server/CommissioningProxySessionManager.h>
+#include <app/clusters/commissioning-proxy-server/CommissioningProxyTransport.h>
+#include <app/server-cluster/DefaultServerCluster.h>
+#include <app/server-cluster/OptionalAttributeSet.h>
+#include <clusters/CommissioningProxy/AttributeIds.h>
+#include <clusters/CommissioningProxy/ClusterId.h>
+#include <clusters/CommissioningProxy/Enums.h>
+#include <clusters/CommissioningProxy/Events.h>
+#include <clusters/CommissioningProxy/Structs.h>
+#include <credentials/FabricTable.h>
+#include <lib/core/CHIPConfig.h>
+#include <lib/core/Optional.h>
+#include <lib/support/TimerDelegate.h>
+
+namespace chip {
+namespace app {
+namespace Clusters {
+namespace CommissioningProxy {
+
+class CommissioningProxyCluster : public DefaultServerCluster, public ScanCacheObserver, public FabricTable::Delegate
+{
+private:
+    using OptionalAttributesSet =
+        OptionalAttributeSet<CommissioningProxy::Attributes::MaxCachedResults::Id,
+                             CommissioningProxy::Attributes::NumCachedResults::Id, CommissioningProxy::Attributes::CacheTimeout::Id,
+                             CommissioningProxy::Attributes::CachedResults::Id, CommissioningProxy::Attributes::WiFiBand::Id>;
+
+public:
+    enum State
+    {
+        kState_CPDisconnected = 0,
+        kState_CPConnected
+    };
+
+    // Static device capabilities. The app supplies these up front; transports are
+    // registered separately (RegisterTransport) since they may be constructed after
+    // the cluster. There is no application delegate: all transport actions go through
+    // the registered CommissioningProxyTransport drivers, and all reportable/derived
+    // attribute state is owned by the cluster and its subsystems.
+    // MaxSessions and MaxCachedResults are Fixed-quality attributes, so they are build
+    // -time device characteristics rather than per-instance config: they come from
+    // CHIP_CONFIG_COMMISSIONING_PROXY_MAX_SESSIONS / _MAX_CACHED_RESULTS, which also
+    // size the storage behind them.
+    struct Config
+    {
+        BitMask<CommissioningProxy::Feature> featureFlags;
+        BitMask<CommissioningProxy::WiFiBandBitmap> supportedWiFiBands;
+
+        Config(BitMask<CommissioningProxy::Feature> aFeatures,
+               BitMask<CommissioningProxy::WiFiBandBitmap> aSupportedWiFiBands = {}) :
+            featureFlags(aFeatures),
+            supportedWiFiBands(aSupportedWiFiBands)
+        {}
+    };
+
+    CommissioningProxyCluster() = delete;
+
+    // The endpoint id is supplied separately from Config: a device's cluster config is
+    // typically fixed up front, but the endpoint is only known when the device is
+    // registered and the clusters are created.
+    // @p timerDelegate supplies the response-timeout and scan-watchdog timers. Apps
+    // normally pass a chip::app::DefaultTimerDelegate (see CodegenIntegration.cpp);
+    // tests substitute their own to drive expiry without the event loop.
+    //
+    // @p fabricTable is watched so a removed fabric's sessions and background scans go
+    // with it. May be nullptr where no FabricTable exists (unit tests), in which case
+    // OnFabricRemoved must be driven directly.
+    CommissioningProxyCluster(EndpointId endpointId, const Config & config, TimerDelegate & timerDelegate,
+                              FabricTable * fabricTable = nullptr) :
+        DefaultServerCluster({ endpointId, CommissioningProxy::Id }),
+        mFeatureFlags(config.featureFlags), mSupportedWiFiBands(config.supportedWiFiBands), mEnabledOptionalAttributes([&]() {
+            OptionalAttributesSet attrs;
+            attrs.Set<CommissioningProxy::Attributes::MaxCachedResults::Id>(
+                config.featureFlags.Has(CommissioningProxy::Feature::kBackgroundScan));
+            attrs.Set<CommissioningProxy::Attributes::NumCachedResults::Id>(
+                config.featureFlags.Has(CommissioningProxy::Feature::kBackgroundScan));
+            attrs.Set<CommissioningProxy::Attributes::CacheTimeout::Id>(
+                config.featureFlags.Has(CommissioningProxy::Feature::kBackgroundScan));
+            attrs.Set<CommissioningProxy::Attributes::CachedResults::Id>(
+                config.featureFlags.Has(CommissioningProxy::Feature::kBackgroundScan));
+            attrs.Set<CommissioningProxy::Attributes::WiFiBand::Id>(
+                config.featureFlags.Has(CommissioningProxy::Feature::kWiFiNetworkInterface));
+            return attrs;
+        }()),
+        mSessions(timerDelegate), mScanCache(*this, timerDelegate), mScanAggregator(timerDelegate), mFabricTable(fabricTable)
+    {
+        mMainCommissioningProxyState = kState_CPDisconnected;
+    }
+
+    ~CommissioningProxyCluster() override
+    {
+        // Safety net: idempotent if the application already called Shutdown().
+        Shutdown(ClusterShutdownType::kClusterShutdown);
+    }
+
+    // Cancel subsystem timers, detach transports, and clear IM context.  Must be
+    // called (or destructor invoked) before any stack-allocated CommandHandler
+    // that was passed to InvokeCommand() goes out of scope.
+    void Shutdown(ClusterShutdownType type) override
+    {
+        if (mFabricTable != nullptr)
+        {
+            mFabricTable->RemoveFabricDelegate(this);
+        }
+        mScanCache.Shutdown();
+        mScanAggregator.Shutdown();
+        mSessions.Shutdown();
+        for (size_t i = 0; i < mTransportCount; i++)
+        {
+            mTransports[i]->Shutdown();
+            mTransports[i]->SetHost(nullptr);
+        }
+        mTransportCount = 0;
+        DefaultServerCluster::Shutdown(type);
+    }
+
+    /**
+     * @brief Register a platform transport driver. The driver's host back-pointer
+     * is set to this cluster. Call before Startup; a driver for a given transport
+     * bit may be registered only once.
+     */
+    void RegisterTransport(CommissioningProxyTransport & transport)
+    {
+        VerifyOrDie(mTransportCount < kMaxTransports);
+        VerifyOrDie(FindTransport(transport.GetTransportType()) == nullptr);
+        mTransports[mTransportCount++] = &transport;
+        transport.SetHost(this);
+    }
+
+    const OptionalAttributesSet & OptionalAttributes() const { return mEnabledOptionalAttributes; }
+    const BitFlags<CommissioningProxy::Feature> & Features() const { return mFeatureFlags; }
+
+    CHIP_ERROR Startup(ServerClusterContext & context) override;
+
+    /**
+     * @brief Tear down everything a removed fabric owned: any in-flight connect, its
+     *        proxy sessions, and its background-scan requests on every transport.
+     */
+    void OnFabricRemoved(const FabricTable & fabricTable, FabricIndex fabricIndex) override;
+
+    DataModel::ActionReturnStatus ReadAttribute(const DataModel::ReadAttributeRequest & request,
+                                                AttributeValueEncoder & encoder) override;
+    DataModel::ActionReturnStatus WriteAttribute(const DataModel::WriteAttributeRequest & request,
+                                                 AttributeValueDecoder & decoder) override;
+    std::optional<DataModel::ActionReturnStatus> InvokeCommand(const DataModel::InvokeRequest & request,
+                                                               TLV::TLVReader & input_arguments, CommandHandler * handler) override;
+    CHIP_ERROR Attributes(const ConcreteClusterPath & path, ReadOnlyBufferBuilder<DataModel::AttributeEntry> & builder) override;
+    CHIP_ERROR AcceptedCommands(const ConcreteClusterPath & path,
+                                ReadOnlyBufferBuilder<DataModel::AcceptedCommandEntry> & builder) override;
+    CHIP_ERROR GeneratedCommands(const ConcreteClusterPath & path, ReadOnlyBufferBuilder<CommandId> & builder) override;
+    CHIP_ERROR SetCPState(State state);
+    CommissioningProxyCluster::State GetCPState();
+
+    /**
+     * @brief Current ScanMaxTime / CacheTimeout attribute values.
+     *
+     * These writable attributes are stored and change-reported by the cluster.
+     * A transport performing a scan reads the live value through these getters
+     * (via its host back-pointer) rather than caching its own copy.
+     */
+    uint8_t GetScanMaxTime() const { return mScanMaxTime; }
+    uint16_t GetCacheTimeout() const override { return mCacheTimeout; }
+
+    // Static device capabilities (from Config).
+    uint8_t GetMaxSessions() const { return mMaxSessions; }
+    uint8_t GetMaxCachedResults() const override { return mMaxCachedResults; }
+    BitMask<CommissioningProxy::WiFiBandBitmap> GetSupportedWiFiBands() const { return mSupportedWiFiBands; }
+
+    /// Update the supported Wi-Fi bands (e.g. once the radio's capabilities are
+    /// known at init). Also the Config default.
+    void SetSupportedWiFiBands(BitMask<CommissioningProxy::WiFiBandBitmap> bands) { mSupportedWiFiBands = bands; }
+
+    /// Number of proxy sessions counted against MaxSessions: established sessions
+    /// plus any in-flight connect on any registered transport.
+    uint8_t GetActiveSessionCount() const
+    {
+        uint8_t count = mSessions.ActiveCount();
+        for (size_t i = 0; i < mTransportCount; i++)
+        {
+            if (mTransports[i]->IsConnectPending())
+            {
+                count++;
+            }
+        }
+        return count;
+    }
+
+    /// The set of transports supported by this instance: those with a registered
+    /// driver. Mirrors the Transport attribute and drives command validation.
+    BitMask<CapabilitiesBitmap> GetSupportedTransports() const;
+
+    /// Find the registered driver for a single transport bit, or nullptr.
+    CommissioningProxyTransport * FindTransport(CapabilitiesBitmap bit) const;
+
+    /**
+     * @brief Accessors for the cluster's transport-agnostic subsystems. Platform
+     * transports call these (via their host back-pointer) to register/close
+     * sessions, route ProxyMessage replies, cache background-scan results, and
+     * contribute foreground-scan results.
+     */
+    CommissioningProxySessionManager & Sessions() { return mSessions; }
+    CommissioningProxyScanCache & ScanCache() { return mScanCache; }
+    CommissioningProxyScanAggregator & ScanAggregator() { return mScanAggregator; }
+
+    /**
+     * @brief Notify subscribers that CachedResults and NumCachedResults have changed.
+     *
+     * Called by the scan cache whenever the background-scan result set changes.
+     */
+    void MarkCachedResultsDirty() override
+    {
+        NotifyAttributeChanged(CommissioningProxy::Attributes::CachedResults::Id);
+        NotifyAttributeChanged(CommissioningProxy::Attributes::NumCachedResults::Id);
+    }
+
+private:
+    std::optional<DataModel::ActionReturnStatus>
+    HandleProxyConnectRequest(const DataModel::InvokeRequest & request, TLV::TLVReader & input_arguments, CommandHandler * handler);
+    std::optional<DataModel::ActionReturnStatus> HandleProxyDisconnectRequest(const DataModel::InvokeRequest & request,
+                                                                              TLV::TLVReader & input_arguments,
+                                                                              CommandHandler * handler);
+    std::optional<DataModel::ActionReturnStatus> HandleProxyScanRequest(const DataModel::InvokeRequest & request,
+                                                                        TLV::TLVReader & input_arguments, CommandHandler * handler);
+    std::optional<DataModel::ActionReturnStatus> HandleProxyBackGroundScanStartRequest(const DataModel::InvokeRequest & request,
+                                                                                       TLV::TLVReader & input_arguments,
+                                                                                       CommandHandler * handler);
+    std::optional<DataModel::ActionReturnStatus> HandleProxyBackGroundScanStopRequest(const DataModel::InvokeRequest & request,
+                                                                                      TLV::TLVReader & input_arguments,
+                                                                                      CommandHandler * handler);
+    std::optional<DataModel::ActionReturnStatus>
+    HandleProxyMessageRequest(const DataModel::InvokeRequest & request, TLV::TLVReader & input_arguments, CommandHandler * handler);
+
+    /// Cancel any in-flight connect(s) owned by @p fabricIndex across all transports
+    /// (null-SessionID ProxyDisconnectRequest).
+    Protocols::InteractionModel::Status CancelPendingConnect(FabricIndex fabricIndex);
+
+    const BitFlags<CommissioningProxy::Feature> mFeatureFlags;
+    static constexpr uint8_t mMaxSessions      = CHIP_CONFIG_COMMISSIONING_PROXY_MAX_SESSIONS;
+    static constexpr uint8_t mMaxCachedResults = CHIP_CONFIG_COMMISSIONING_PROXY_MAX_CACHED_RESULTS;
+    BitMask<CommissioningProxy::WiFiBandBitmap> mSupportedWiFiBands;
+    const OptionalAttributesSet mEnabledOptionalAttributes;
+    State mMainCommissioningProxyState;
+
+    // Writable attributes owned (stored and change-reported) by the cluster.
+    uint8_t mScanMaxTime   = 10;
+    uint16_t mCacheTimeout = 120;
+
+    // Transport-agnostic subsystems owned by the cluster.
+    CommissioningProxySessionManager mSessions;
+    CommissioningProxyScanCache mScanCache;
+    CommissioningProxyScanAggregator mScanAggregator;
+
+    FabricTable * mFabricTable;
+
+    // Registered platform transport drivers (BLE, Wi-Fi PAF, ...), at most one per
+    // transport bit. Owned by the application, not the cluster.
+    static constexpr size_t kMaxTransports                    = 3;
+    CommissioningProxyTransport * mTransports[kMaxTransports] = {};
+    size_t mTransportCount                                    = 0;
+};
+
+} // namespace CommissioningProxy
+} // namespace Clusters
+} // namespace app
+} // namespace chip
diff --git a/src/app/clusters/commissioning-proxy-server/CommissioningProxyScanAggregator.cpp b/src/app/clusters/commissioning-proxy-server/CommissioningProxyScanAggregator.cpp
new file mode 100644
index 0000000..0fe60eb
--- /dev/null
+++ b/src/app/clusters/commissioning-proxy-server/CommissioningProxyScanAggregator.cpp
@@ -0,0 +1,179 @@
+/*
+ *
+ *    Copyright (c) 2026 Project CHIP Authors
+ *    All rights reserved.
+ *
+ *    Licensed under the Apache License, Version 2.0 (the "License");
+ *    you may not use this file except in compliance with the License.
+ *    You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *    Unless required by applicable law or agreed to in writing, software
+ *    distributed under the License is distributed on an "AS IS" BASIS,
+ *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *    See the License for the specific language governing permissions and
+ *    limitations under the License.
+ */
+#include <app/clusters/commissioning-proxy-server/CommissioningProxyScanAggregator.h>
+
+#include <clusters/CommissioningProxy/Commands.h>
+#include <lib/support/logging/CHIPLogging.h>
+#include <system/SystemClock.h>
+
+#include <algorithm>
+#include <cstring>
+
+namespace chip {
+namespace app {
+namespace Clusters {
+namespace CommissioningProxy {
+
+namespace {
+// Fallback so a sub-scan whose completion callback never fires cannot wedge the
+// aggregator (and thus every future ProxyScanRequest) permanently.
+constexpr System::Clock::Seconds16 kScanWatchdogMargin{ 5 };
+} // namespace
+
+CHIP_ERROR CommissioningProxyScanAggregator::Begin(app::CommandHandler * commandObj, const app::ConcreteCommandPath & path,
+                                                   System::Clock::Seconds16 scanMaxTime)
+{
+    mHandle                    = app::CommandHandler::Handle(commandObj);
+    mPath                      = path;
+    mExpected                  = 0;
+    mReported                  = 0;
+    mScanMaxTime               = scanMaxTime;
+    mResultCount               = 0;
+    mInProgress                = true;
+    mAllContributorsRegistered = false;
+
+    CHIP_ERROR err = mTimerDelegate.StartTimer(this, scanMaxTime + kScanWatchdogMargin);
+    if (err != CHIP_NO_ERROR)
+    {
+        // With no watchdog a sub-scan that never reports would leave mInProgress set
+        // forever, making every later ProxyScanRequest return Busy.
+        ChipLogError(Zcl, "CommissioningProxy: failed to arm scan watchdog: %" CHIP_ERROR_FORMAT, err.Format());
+        Abort();
+        return err;
+    }
+
+    return CHIP_NO_ERROR;
+}
+
+void CommissioningProxyScanAggregator::AddPendingContributor()
+{
+    ++mExpected;
+}
+
+void CommissioningProxyScanAggregator::TimerFired()
+{
+    if (mInProgress)
+    {
+        ChipLogError(Zcl, "CommissioningProxy: scan watchdog fired (a sub-scan never completed); emitting partial results");
+        EmitCombinedResponse();
+    }
+}
+
+void CommissioningProxyScanAggregator::EmitCombinedResponse()
+{
+    mTimerDelegate.CancelTimer(this);
+
+    if (app::CommandHandler * cmd = mHandle.Get())
+    {
+        Commands::ProxyScanResponse::Type response;
+        response.proxyScanResult = DataModel::List<const ScanResultEntry>(Span<const ScanResultEntry>(mResults, mResultCount));
+        response.numberOfResults = mResultCount;
+        cmd->AddResponse(mPath, response);
+        ChipLogProgress(Zcl, "CommissioningProxy: combined scan complete, %u result(s)", mResultCount);
+    }
+
+    mHandle.Release();
+    mResultCount               = 0;
+    mExpected                  = 0;
+    mReported                  = 0;
+    mInProgress                = false;
+    mAllContributorsRegistered = false;
+}
+
+void CommissioningProxyScanAggregator::Contribute(Span<const ScanResultEntry> results)
+{
+    if (!mInProgress)
+    {
+        ChipLogError(Zcl, "CommissioningProxy: no aggregate scan active; dropping %u result(s)",
+                     static_cast<unsigned>(results.size()));
+        return;
+    }
+
+    for (const auto & e : results)
+    {
+        // Spec: ProxyScanResult is "max MaxCachedResults" entries.
+        if (mResultCount >= kMaxResults)
+        {
+            ChipLogError(Zcl, "CommissioningProxy: scan result list full; dropping remaining results");
+            break;
+        }
+
+        // Copy scalar fields, then rebind the address / extendedData spans to point at
+        // this aggregator's own storage so they survive until AddResponse.
+        ResultStore & store = mStore[mResultCount];
+        ScanResultEntry r   = e;
+        if (!e.address.IsNull())
+        {
+            auto span            = e.address.Value();
+            const size_t copyLen = std::min(span.size(), kMaxAddressBytes);
+            memcpy(store.address, span.data(), copyLen);
+            store.addressLen = static_cast<uint8_t>(copyLen);
+            r.address.SetNonNull(ByteSpan(store.address, store.addressLen));
+        }
+        if (!e.extendedData.IsNull())
+        {
+            auto span            = e.extendedData.Value();
+            const size_t copyLen = std::min(span.size(), kMaxExtendedDataBytes);
+            memcpy(store.extendedData, span.data(), copyLen);
+            store.extendedDataLen = static_cast<uint8_t>(copyLen);
+            r.extendedData.SetNonNull(ByteSpan(store.extendedData, store.extendedDataLen));
+        }
+        mResults[mResultCount] = r;
+        mResultCount++;
+    }
+
+    // Always count the report. A transport that reports synchronously from within Scan()
+    // contributes while mExpected is still growing, so emitting here would drop every
+    // later transport's results; MaybeEmitIfComplete() finishes those aggregations once
+    // the cluster has started all of the sub-scans.
+    ++mReported;
+    if (mAllContributorsRegistered && mExpected > 0 && mReported >= mExpected)
+    {
+        EmitCombinedResponse();
+    }
+}
+
+void CommissioningProxyScanAggregator::MaybeEmitIfComplete()
+{
+    // The cluster has started every requested sub-scan, so mExpected is final now.
+    mAllContributorsRegistered = true;
+    if (mInProgress && mExpected > 0 && mReported >= mExpected)
+    {
+        EmitCombinedResponse();
+    }
+}
+
+void CommissioningProxyScanAggregator::Abort()
+{
+    if (!mInProgress)
+    {
+        return;
+    }
+    mTimerDelegate.CancelTimer(this);
+    mHandle.Release();
+    mResultCount               = 0;
+    mExpected                  = 0;
+    mReported                  = 0;
+    mInProgress                = false;
+    mAllContributorsRegistered = false;
+}
+
+} // namespace CommissioningProxy
+} // namespace Clusters
+} // namespace app
+} // namespace chip
diff --git a/src/app/clusters/commissioning-proxy-server/CommissioningProxyScanAggregator.h b/src/app/clusters/commissioning-proxy-server/CommissioningProxyScanAggregator.h
new file mode 100644
index 0000000..44a2183
--- /dev/null
+++ b/src/app/clusters/commissioning-proxy-server/CommissioningProxyScanAggregator.h
@@ -0,0 +1,143 @@
+/*
+ *
+ *    Copyright (c) 2026 Project CHIP Authors
+ *    All rights reserved.
+ *
+ *    Licensed under the Apache License, Version 2.0 (the "License");
+ *    you may not use this file except in compliance with the License.
+ *    You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *    Unless required by applicable law or agreed to in writing, software
+ *    distributed under the License is distributed on an "AS IS" BASIS,
+ *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *    See the License for the specific language governing permissions and
+ *    limitations under the License.
+ */
+
+#pragma once
+
+#include <app/CommandHandler.h>
+#include <app/ConcreteCommandPath.h>
+#include <clusters/CommissioningProxy/Structs.h>
+#include <lib/core/CHIPConfig.h>
+#include <lib/core/CHIPError.h>
+#include <lib/support/Span.h>
+#include <lib/support/TimerDelegate.h>
+#include <system/SystemClock.h>
+
+#include <cstdint>
+
+namespace chip {
+namespace app {
+namespace Clusters {
+namespace CommissioningProxy {
+
+/**
+ * @brief Aggregates a multi-transport foreground ProxyScanRequest into one response.
+ *
+ * A ProxyScanRequest MAY select more than one transport. The cluster starts each
+ * requested transport's scan in parallel, hands this aggregator the command handle
+ * and the number of started sub-scans, and each transport reports its results via
+ * Contribute() when its sub-scan finishes. Once every started sub-scan has reported
+ * (or a watchdog fires), the aggregator emits the single combined ProxyScanResponse.
+ *
+ * Only one aggregation may be in flight at a time (matches a single outstanding
+ * foreground scan). Entry points run on the Matter thread with the stack lock held.
+ */
+class CommissioningProxyScanAggregator : public TimerContext
+{
+public:
+    using ScanResultEntry = Structs::ScanResultStruct::Type;
+
+    CommissioningProxyScanAggregator() = delete;
+    explicit CommissioningProxyScanAggregator(TimerDelegate & timerDelegate) : mTimerDelegate(timerDelegate) {}
+    ~CommissioningProxyScanAggregator() override = default;
+
+    /**
+     * @brief Start a fresh aggregation. Takes ownership of the command handle for
+     *        the combined response and arms a watchdog bounded by @p scanMaxTime.
+     *        The caller must have checked InProgress() == false first.
+     *
+     * The number of contributors is not known up front: the caller starts each
+     * requested transport's scan and calls AddPendingContributor() for every one
+     * that starts successfully, then MaybeEmitIfComplete() (or Abort() if none started).
+     *
+     * Returns an error (leaving InProgress() false) if the watchdog could not be
+     * armed; the caller must reject the command rather than scan unbounded.
+     */
+    CHIP_ERROR Begin(app::CommandHandler * commandObj, const app::ConcreteCommandPath & path, System::Clock::Seconds16 scanMaxTime);
+
+    /// Register one successfully-started sub-scan (increments the expected count).
+    void AddPendingContributor();
+
+    /// Number of sub-scans registered via AddPendingContributor().
+    uint8_t PendingContributors() const { return mExpected; }
+
+    /**
+     * @brief Report one transport's sub-scan results (empty span = found nothing).
+     *        Entries are deep-copied, so the caller's backing storage need not
+     *        outlive the call. Emits the combined ProxyScanResponse once every
+     *        registered contributor has reported.
+     */
+    void Contribute(Span<const ScanResultEntry> results);
+
+    /// Marks the expected count final and emits the combined response if every
+    /// registered contributor has already reported. Call once after starting all
+    /// sub-scans, to cover transports that contribute synchronously from within
+    /// Scan() (real transports report later, via Contribute()).
+    void MaybeEmitIfComplete();
+
+    /// True while an aggregation is in flight.
+    bool InProgress() const { return mInProgress; }
+
+    /// Abandon the in-flight aggregation without sending a response (no sub-scan
+    /// could be started, or cluster teardown).
+    void Abort();
+
+    /// Abandon any in-flight aggregation (cluster teardown).
+    void Shutdown() { Abort(); }
+
+    /// Watchdog expiry: a sub-scan never reported, so emit whatever did arrive rather
+    /// than leave the aggregation (and every later ProxyScanRequest) blocked.
+    void TimerFired() override;
+
+private:
+    void EmitCombinedResponse();
+
+    // Spec: ProxyScanResult is constrained to "max MaxCachedResults", so the response
+    // is bounded by the same build-time limit as the cache, with Address/ExtendedData
+    // held inline at their spec maxima.
+    static constexpr size_t kMaxResults           = CHIP_CONFIG_COMMISSIONING_PROXY_MAX_CACHED_RESULTS;
+    static constexpr size_t kMaxAddressBytes      = 100;
+    static constexpr size_t kMaxExtendedDataBytes = 128;
+
+    struct ResultStore
+    {
+        uint8_t address[kMaxAddressBytes];
+        uint8_t addressLen = 0;
+        uint8_t extendedData[kMaxExtendedDataBytes];
+        uint8_t extendedDataLen = 0;
+    };
+
+    TimerDelegate & mTimerDelegate;
+    app::CommandHandler::Handle mHandle;
+    app::ConcreteCommandPath mPath;
+    bool mInProgress = false;
+    // Set by MaybeEmitIfComplete() once the caller has started every requested sub-scan.
+    // Until then mExpected is still growing, so a contributor reporting synchronously
+    // from within Scan() must not be allowed to satisfy it and emit early.
+    bool mAllContributorsRegistered = false;
+    uint8_t mExpected               = 0;
+    uint8_t mReported               = 0;
+    System::Clock::Seconds16 mScanMaxTime{ 0 };
+    ResultStore mStore[kMaxResults]; // keeps ByteSpan backing alive until emit
+    ScanResultEntry mResults[kMaxResults];
+    uint8_t mResultCount = 0;
+};
+
+} // namespace CommissioningProxy
+} // namespace Clusters
+} // namespace app
+} // namespace chip
diff --git a/src/app/clusters/commissioning-proxy-server/CommissioningProxyScanCache.cpp b/src/app/clusters/commissioning-proxy-server/CommissioningProxyScanCache.cpp
new file mode 100644
index 0000000..fbdf507
--- /dev/null
+++ b/src/app/clusters/commissioning-proxy-server/CommissioningProxyScanCache.cpp
@@ -0,0 +1,250 @@
+/*
+ *
+ *    Copyright (c) 2026 Project CHIP Authors
+ *    All rights reserved.
+ *
+ *    Licensed under the Apache License, Version 2.0 (the "License");
+ *    you may not use this file except in compliance with the License.
+ *    You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *    Unless required by applicable law or agreed to in writing, software
+ *    distributed under the License is distributed on an "AS IS" BASIS,
+ *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *    See the License for the specific language governing permissions and
+ *    limitations under the License.
+ */
+#include <app/clusters/commissioning-proxy-server/CommissioningProxyScanCache.h>
+
+#include <lib/support/logging/CHIPLogging.h>
+
+#include <algorithm>
+#include <cstring>
+#include <system/SystemClock.h>
+
+namespace chip {
+namespace app {
+namespace Clusters {
+namespace CommissioningProxy {
+
+namespace {
+// One periodic sweep (1 s granularity) reaps expired entries while the cache is
+// non-empty — far cheaper than a per-device TTL timer re-armed on every advert.
+constexpr System::Clock::Timeout kSweepInterval = System::Clock::Seconds16(1);
+} // namespace
+
+bool CommissioningProxyScanCache::Key::operator==(const Key & o) const
+{
+    return transport == o.transport && discriminator == o.discriminator && vid == o.vid && pid == o.pid;
+}
+
+CommissioningProxyScanCache::Entry * CommissioningProxyScanCache::FindEntry(const Key & key)
+{
+    for (auto & entry : mEntries)
+    {
+        if (entry.inUse && entry.key == key)
+        {
+            return &entry;
+        }
+    }
+    return nullptr;
+}
+
+void CommissioningProxyScanCache::ArmSweepIfNeeded()
+{
+    if (mSweepArmed || Count() == 0)
+    {
+        return;
+    }
+    if (mTimerDelegate.StartTimer(this, kSweepInterval) == CHIP_NO_ERROR)
+    {
+        mSweepArmed = true;
+    }
+}
+
+void CommissioningProxyScanCache::OnSweep()
+{
+    mSweepArmed = false;
+
+    auto now     = mTimerDelegate.GetCurrentMonotonicTimestamp();
+    bool removed = false;
+    for (auto & entry : mEntries)
+    {
+        if (entry.inUse && entry.expiresAt <= now)
+        {
+            ChipLogProgress(Zcl, "CommissioningProxyScanCache: TTL expired for discriminator %u (transport 0x%x)",
+                            entry.key.discriminator, entry.key.transport);
+            entry.inUse = false;
+            removed     = true;
+        }
+    }
+
+    if (removed)
+    {
+        mCluster.MarkCachedResultsDirty();
+    }
+
+    ArmSweepIfNeeded();
+}
+
+void CommissioningProxyScanCache::Report(const ScanResultEntry & result)
+{
+    Key key{ static_cast<uint8_t>(result.transport.Raw()), result.discriminator, static_cast<uint16_t>(result.vendorID),
+             result.productID };
+
+    auto expiresAt = mTimerDelegate.GetCurrentMonotonicTimestamp() + System::Clock::Seconds16(mCluster.GetCacheTimeout());
+
+    if (Entry * existing = FindEntry(key))
+    {
+        // Re-discovery: refresh TTL only (the visible result is unchanged, so no dirty).
+        existing->expiresAt = expiresAt;
+        ArmSweepIfNeeded();
+        return;
+    }
+
+    Entry * slot = nullptr;
+    for (auto & candidate : mEntries)
+    {
+        if (!candidate.inUse)
+        {
+            slot = &candidate;
+            break;
+        }
+    }
+    if (slot == nullptr)
+    {
+        ChipLogDetail(Zcl, "CommissioningProxyScanCache: full (%u entries), dropping discriminator %u",
+                      mCluster.GetMaxCachedResults(), key.discriminator);
+        return;
+    }
+
+    *slot           = Entry{};
+    slot->inUse     = true;
+    slot->key       = key;
+    slot->transport = result.transport;
+    if (!result.address.IsNull())
+    {
+        auto span            = result.address.Value();
+        const size_t copyLen = std::min(span.size(), kMaxAddressBytes);
+        memcpy(slot->address, span.data(), copyLen);
+        slot->addressLen = static_cast<uint8_t>(copyLen);
+        slot->hasAddress = true;
+    }
+    slot->discriminator = result.discriminator;
+    slot->vendorID      = result.vendorID;
+    slot->productID     = result.productID;
+    if (!result.extendedData.IsNull())
+    {
+        auto span            = result.extendedData.Value();
+        const size_t copyLen = std::min(span.size(), kMaxExtendedDataBytes);
+        memcpy(slot->extendedData, span.data(), copyLen);
+        slot->extendedDataLen = static_cast<uint8_t>(copyLen);
+        slot->hasExtendedData = true;
+    }
+    slot->wiFiBand  = result.wiFiBand;
+    slot->expiresAt = expiresAt;
+
+    ChipLogProgress(Zcl, "CommissioningProxyScanCache: cached discriminator %u (transport 0x%x, total=%u)", key.discriminator,
+                    key.transport, Count());
+
+    mCluster.MarkCachedResultsDirty();
+    ArmSweepIfNeeded();
+}
+
+void CommissioningProxyScanCache::ClearTransport(BitMask<CapabilitiesBitmap> transport, BitMask<WiFiBandBitmap> bands)
+{
+    // Only PAFTP results carry a band, so a BLE stop always arrives with bands == 0.
+    const BitMask<WiFiBandBitmap> kBandFallback{ WiFiBandBitmap::k2g4 };
+
+    bool removed = false;
+    for (auto & entry : mEntries)
+    {
+        if (!entry.inUse)
+        {
+            continue;
+        }
+        const bool bandMatches = bands.Raw() == 0 || (entry.wiFiBand.ValueOr(kBandFallback).Raw() & bands.Raw()) != 0;
+        if ((entry.key.transport & transport.Raw()) != 0 && bandMatches)
+        {
+            entry.inUse = false;
+            removed     = true;
+        }
+    }
+
+    if (removed)
+    {
+        mCluster.MarkCachedResultsDirty();
+    }
+}
+
+uint8_t CommissioningProxyScanCache::Count() const
+{
+    uint8_t count = 0;
+    for (const auto & entry : mEntries)
+    {
+        count = static_cast<uint8_t>(count + (entry.inUse ? 1 : 0));
+    }
+    return count;
+}
+
+CHIP_ERROR CommissioningProxyScanCache::Encode(AttributeValueEncoder & encoder) const
+{
+    if (Count() == 0)
+    {
+        DataModel::Nullable<DataModel::List<const ScanResultEntry>> nullValue;
+        return encoder.Encode(nullValue);
+    }
+
+    return encoder.EncodeList([this](const auto & listEncoder) -> CHIP_ERROR {
+        for (const auto & e : mEntries)
+        {
+            if (!e.inUse)
+            {
+                continue;
+            }
+            ScanResultEntry r{};
+            if (e.hasAddress)
+            {
+                r.address.SetNonNull(ByteSpan(e.address, e.addressLen));
+            }
+            else
+            {
+                r.address.SetNull();
+            }
+            r.transport     = e.transport;
+            r.discriminator = e.discriminator;
+            r.vendorID      = e.vendorID;
+            r.productID     = e.productID;
+            if (e.hasExtendedData)
+            {
+                r.extendedData.SetNonNull(ByteSpan(e.extendedData, e.extendedDataLen));
+            }
+            else
+            {
+                r.extendedData.SetNull();
+            }
+            r.wiFiBand = e.wiFiBand;
+            ReturnErrorOnFailure(listEncoder.Encode(r));
+        }
+        return CHIP_NO_ERROR;
+    });
+}
+
+void CommissioningProxyScanCache::Shutdown()
+{
+    if (mSweepArmed)
+    {
+        mTimerDelegate.CancelTimer(this);
+        mSweepArmed = false;
+    }
+    for (auto & entry : mEntries)
+    {
+        entry.inUse = false;
+    }
+}
+
+} // namespace CommissioningProxy
+} // namespace Clusters
+} // namespace app
+} // namespace chip
diff --git a/src/app/clusters/commissioning-proxy-server/CommissioningProxyScanCache.h b/src/app/clusters/commissioning-proxy-server/CommissioningProxyScanCache.h
new file mode 100644
index 0000000..1ceef98
--- /dev/null
+++ b/src/app/clusters/commissioning-proxy-server/CommissioningProxyScanCache.h
@@ -0,0 +1,161 @@
+/*
+ *
+ *    Copyright (c) 2026 Project CHIP Authors
+ *    All rights reserved.
+ *
+ *    Licensed under the Apache License, Version 2.0 (the "License");
+ *    you may not use this file except in compliance with the License.
+ *    You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *    Unless required by applicable law or agreed to in writing, software
+ *    distributed under the License is distributed on an "AS IS" BASIS,
+ *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *    See the License for the specific language governing permissions and
+ *    limitations under the License.
+ */
+
+#pragma once
+
+#include <app/AttributeValueEncoder.h>
+#include <clusters/CommissioningProxy/Enums.h>
+#include <clusters/CommissioningProxy/Structs.h>
+#include <lib/core/CHIPConfig.h>
+#include <lib/core/CHIPError.h>
+#include <lib/core/Optional.h>
+#include <lib/support/BitMask.h>
+#include <lib/support/TimerDelegate.h>
+#include <system/SystemClock.h>
+
+#include <cstdint>
+
+namespace chip {
+namespace app {
+namespace Clusters {
+namespace CommissioningProxy {
+
+/**
+ * @brief Callbacks from CommissioningProxyScanCache to its owning cluster.
+ *
+ * Defined here (not in CommissioningProxyCluster.h) so CommissioningProxyScanCache.cpp
+ * can call back without including the full cluster header, which lives in a
+ * separate GN target compiled in the consumer's context.
+ */
+class ScanCacheObserver
+{
+public:
+    virtual ~ScanCacheObserver()                = default;
+    virtual void MarkCachedResultsDirty()       = 0;
+    virtual uint16_t GetCacheTimeout() const    = 0;
+    virtual uint8_t GetMaxCachedResults() const = 0;
+};
+
+/**
+ * @brief Transport-agnostic background-scan result cache.
+ *
+ * Backs the CachedResults / NumCachedResults attributes. Per spec, CachedResults is
+ * a single cluster-wide list keyed on discriminator/VendorID/ProductID/Transport, so
+ * every transport shares ONE cache here — one combined MaxCachedResults cap, one
+ * NumCachedResults/CachedResults view, and one TTL sweep timer.
+ *
+ * CacheTimeout and MaxCachedResults are read back from the owning cluster; whenever
+ * the set of entries changes the cache calls the cluster's MarkCachedResultsDirty()
+ * so change-reporting stays the cluster's responsibility (delegates no longer touch
+ * NumCachedResults/CachedResults directly).
+ *
+ * All entry points must run on the Matter thread with the stack lock held.
+ */
+class CommissioningProxyScanCache : public TimerContext
+{
+public:
+    using ScanResultEntry = Structs::ScanResultStruct::Type;
+
+    CommissioningProxyScanCache(ScanCacheObserver & cluster, TimerDelegate & timerDelegate) :
+        mCluster(cluster), mTimerDelegate(timerDelegate)
+    {}
+    /// Idempotent if Shutdown() has already run, and required if it has not: the sweep
+    /// timer would otherwise be left holding a pointer to this destroyed TimerContext.
+    ~CommissioningProxyScanCache() override { Shutdown(); }
+
+    /**
+     * @brief Insert or refresh a discovered device. @p result.transport carries the
+     *        single discovering transport bit. Refreshes the entry's TTL if already
+     *        cached; otherwise inserts subject to the MaxCachedResults cap. Marks
+     *        CachedResults dirty on any change and (re)arms the sweep timer.
+     */
+    void Report(const ScanResultEntry & result);
+
+    /**
+     * @brief Drop cached results for a scan that has stopped. @p bands == 0 means the
+     *        transport itself stopped, so every entry on it goes; otherwise only
+     *        entries discovered on those bands go. Spec gives a ScanResultStruct with
+     *        no WiFiBand the fallback value 2G4, which is applied when matching.
+     *        Marks dirty if anything was removed.
+     */
+    void ClearTransport(BitMask<CapabilitiesBitmap> transport, BitMask<WiFiBandBitmap> bands = {});
+
+    /// NumCachedResults: current combined entry count.
+    uint8_t Count() const;
+
+    /// Encode the CachedResults list attribute (NullNullable when empty).
+    CHIP_ERROR Encode(app::AttributeValueEncoder & encoder) const;
+
+    /// Cancel the sweep timer and drop all entries (cluster teardown).
+    void Shutdown();
+
+    void TimerFired() override { OnSweep(); }
+
+private:
+    // A device is unique per discriminator/VendorID/ProductID/Transport (spec).
+    struct Key
+    {
+        uint8_t transport;
+        uint16_t discriminator;
+        uint16_t vid;
+        uint16_t pid;
+        bool operator==(const Key & o) const;
+    };
+
+    // ScanResultStruct constrains Address to 100 bytes and ExtendedData to 128, so both
+    // are held inline rather than heap-allocated per entry.
+    static constexpr size_t kMaxAddressBytes      = 100;
+    static constexpr size_t kMaxExtendedDataBytes = 128;
+
+    // Self-owned copy of a ScanResultStruct so its ByteSpans survive in the cache.
+    // `inUse` false marks a free slot; the table is small (MaxCachedResults).
+    struct Entry
+    {
+        bool inUse      = false;
+        Key key         = {};
+        bool hasAddress = false;
+        uint8_t address[kMaxAddressBytes];
+        uint8_t addressLen = 0;
+        BitMask<CapabilitiesBitmap> transport{};
+        uint16_t discriminator = 0;
+        VendorId vendorID      = static_cast<VendorId>(0);
+        uint16_t productID     = 0;
+        bool hasExtendedData   = false;
+        uint8_t extendedData[kMaxExtendedDataBytes];
+        uint8_t extendedDataLen = 0;
+        Optional<BitMask<WiFiBandBitmap>> wiFiBand;
+        System::Clock::Timestamp expiresAt{};
+    };
+
+    /// Sweep expiry: drop every entry whose TTL has passed, then re-arm while any
+    /// entry remains.
+    void OnSweep();
+    void ArmSweepIfNeeded();
+
+    Entry * FindEntry(const Key & key);
+
+    ScanCacheObserver & mCluster;
+    TimerDelegate & mTimerDelegate;
+    Entry mEntries[CHIP_CONFIG_COMMISSIONING_PROXY_MAX_CACHED_RESULTS];
+    bool mSweepArmed = false;
+};
+
+} // namespace CommissioningProxy
+} // namespace Clusters
+} // namespace app
+} // namespace chip
diff --git a/src/app/clusters/commissioning-proxy-server/CommissioningProxySessionManager.cpp b/src/app/clusters/commissioning-proxy-server/CommissioningProxySessionManager.cpp
new file mode 100644
index 0000000..7a9d038
--- /dev/null
+++ b/src/app/clusters/commissioning-proxy-server/CommissioningProxySessionManager.cpp
@@ -0,0 +1,270 @@
+/*
+ *
+ *    Copyright (c) 2026 Project CHIP Authors
+ *    All rights reserved.
+ *
+ *    Licensed under the Apache License, Version 2.0 (the "License");
+ *    you may not use this file except in compliance with the License.
+ *    You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *    Unless required by applicable law or agreed to in writing, software
+ *    distributed under the License is distributed on an "AS IS" BASIS,
+ *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *    See the License for the specific language governing permissions and
+ *    limitations under the License.
+ */
+#include <app/clusters/commissioning-proxy-server/CommissioningProxySessionManager.h>
+
+#include <clusters/CommissioningProxy/Commands.h>
+#include <lib/support/logging/CHIPLogging.h>
+#include <messaging/ExchangeContext.h>
+#include <system/SystemClock.h>
+
+#include <utility>
+
+namespace chip {
+namespace app {
+namespace Clusters {
+namespace CommissioningProxy {
+
+using Status = Protocols::InteractionModel::Status;
+
+namespace {
+// Head-room so the IM exchange outlives our own response timer and can still carry
+// the Status::Timeout back to the commissioner.
+constexpr uint16_t kResponseTimeoutMarginSecs = 5;
+} // namespace
+
+CommissioningProxySessionManager::SessionSlot * CommissioningProxySessionManager::FindSlot(uint16_t sessionId)
+{
+    for (auto & slot : mSessions)
+    {
+        if (slot.inUse && slot.sessionId == sessionId)
+        {
+            return &slot;
+        }
+    }
+    return nullptr;
+}
+
+const CommissioningProxySessionManager::SessionSlot * CommissioningProxySessionManager::FindSlot(uint16_t sessionId) const
+{
+    return const_cast<CommissioningProxySessionManager *>(this)->FindSlot(sessionId);
+}
+
+uint8_t CommissioningProxySessionManager::ActiveCount() const
+{
+    uint8_t count = 0;
+    for (const auto & slot : mSessions)
+    {
+        count = static_cast<uint8_t>(count + (slot.inUse ? 1 : 0));
+    }
+    return count;
+}
+
+uint16_t CommissioningProxySessionManager::AllocSessionId()
+{
+    uint16_t id;
+    do
+    {
+        if (mNextSessionId == 0)
+        {
+            mNextSessionId = 1;
+        }
+        id = mNextSessionId++;
+    } while (FindSlot(id) != nullptr);
+    return id;
+}
+
+void CommissioningProxySessionManager::RegisterSession(uint16_t sessionId, CapabilitiesBitmap transport, FabricIndex fabricIndex)
+{
+    SessionSlot * slot = FindSlot(sessionId);
+    if (slot == nullptr)
+    {
+        for (auto & candidate : mSessions)
+        {
+            if (!candidate.inUse)
+            {
+                slot = &candidate;
+                break;
+            }
+        }
+    }
+    // The cluster gates on MaxSessions before connecting, so the pool cannot be full here.
+    VerifyOrDie(slot != nullptr);
+
+    slot->inUse     = true;
+    slot->sessionId = sessionId;
+    slot->info      = SessionInfo{ transport, fabricIndex };
+}
+
+void CommissioningProxySessionManager::RemoveSession(uint16_t sessionId)
+{
+    AbortPending(sessionId);
+    if (SessionSlot * slot = FindSlot(sessionId))
+    {
+        slot->inUse = false;
+    }
+}
+
+std::optional<CommissioningProxySessionManager::SessionInfo> CommissioningProxySessionManager::FindSession(uint16_t sessionId) const
+{
+    const SessionSlot * slot = FindSlot(sessionId);
+    if (slot == nullptr)
+    {
+        return std::nullopt;
+    }
+    return slot->info;
+}
+
+std::optional<uint16_t> CommissioningProxySessionManager::FindAnySessionIdOnFabric(FabricIndex fabricIndex) const
+{
+    for (const auto & slot : mSessions)
+    {
+        if (slot.inUse && slot.info.fabricIndex == fabricIndex)
+        {
+            return slot.sessionId;
+        }
+    }
+    return std::nullopt;
+}
+
+void CommissioningProxySessionManager::OnResponseTimeout(PendingMessage * pm)
+{
+    SessionSlot * slot = FindSlot(pm->sessionId);
+    if (slot == nullptr || slot->pending != pm)
+    {
+        return; // Already resolved (reply arrived or session closed).
+    }
+
+    slot->pending = nullptr;
+    ChipLogProgress(Zcl, "CommissioningProxy: ProxyMessageRequest responseTimeout expired for session %u", pm->sessionId);
+    // Per spec, an expired ProxyMessageRequest ResponseTimeout SHALL return TIMEOUT.
+    if (app::CommandHandler * cmd = pm->handle.Get())
+    {
+        cmd->AddStatus(pm->path, Status::Timeout);
+    }
+    mPendingPool.ReleaseObject(pm);
+}
+
+Status CommissioningProxySessionManager::BeginMessage(uint16_t sessionId, app::CommandHandler * commandObj,
+                                                      const DataModel::InvokeRequest & request, uint8_t responseTimeoutSeconds)
+{
+    SessionSlot * slot = FindSlot(sessionId);
+    VerifyOrReturnError(slot != nullptr, Status::NotFound);
+
+    // Reject if another request for this session is still live; clean up an already
+    // expired one rather than blocking the session forever.
+    if (slot->pending != nullptr)
+    {
+        if (slot->pending->handle.Get() != nullptr)
+        {
+            ChipLogError(Zcl, "CommissioningProxy: session %u already has a pending ProxyMessageRequest (BUSY)", sessionId);
+            return Status::Busy;
+        }
+        mTimerDelegate.CancelTimer(slot->pending);
+        mPendingPool.ReleaseObject(slot->pending);
+        slot->pending = nullptr;
+    }
+
+    auto * pm = mPendingPool.CreateObject(this, app::CommandHandler::Handle(commandObj), request.path, sessionId);
+    VerifyOrReturnError(pm != nullptr, Status::ResourceExhausted);
+    commandObj->FlushAcksRightAwayOnSlowCommand();
+    slot->pending = pm;
+
+    if (auto * exchange = commandObj->GetExchangeContext())
+    {
+        // The exchange must outlive our own timer, otherwise it expires first and the
+        // Status::Timeout that OnResponseTimeout adds never reaches the commissioner.
+        exchange->SetResponseTimeout(
+            System::Clock::Seconds16(static_cast<uint16_t>(responseTimeoutSeconds + kResponseTimeoutMarginSecs)));
+    }
+
+    CHIP_ERROR err = mTimerDelegate.StartTimer(pm, System::Clock::Seconds16(responseTimeoutSeconds));
+    if (err != CHIP_NO_ERROR)
+    {
+        // Nothing else would ever resolve this request: the session would stay Busy for
+        // every later ProxyMessageRequest and the commissioner would get no response.
+        ChipLogError(Zcl, "CommissioningProxy: failed to start ProxyMessage response timer: %" CHIP_ERROR_FORMAT, err.Format());
+        AbortPending(sessionId);
+        return Status::Failure;
+    }
+
+    return Status::Success;
+}
+
+void CommissioningProxySessionManager::AbortPending(uint16_t sessionId)
+{
+    SessionSlot * slot = FindSlot(sessionId);
+    if (slot == nullptr || slot->pending == nullptr)
+    {
+        return;
+    }
+    PendingMessage * pm = slot->pending;
+    slot->pending       = nullptr;
+    mTimerDelegate.CancelTimer(pm);
+    mPendingPool.ReleaseObject(pm);
+}
+
+void CommissioningProxySessionManager::DispatchMessageResponse(uint16_t sessionId, const uint8_t * data, size_t length)
+{
+    SessionSlot * slot = FindSlot(sessionId);
+    if (slot == nullptr || slot->pending == nullptr)
+    {
+        ChipLogDetail(Zcl, "CommissioningProxy: no pending request for session %u — dropping commissionee data", sessionId);
+        return;
+    }
+
+    PendingMessage * pm = slot->pending;
+    slot->pending       = nullptr;
+    mTimerDelegate.CancelTimer(pm);
+
+    if (app::CommandHandler * cmd = pm->handle.Get())
+    {
+        Commands::ProxyMessageResponse::Type response;
+        response.sessionID = sessionId;
+        response.message.SetNonNull(ByteSpan(data, length));
+        cmd->AddResponse(pm->path, response);
+    }
+    mPendingPool.ReleaseObject(pm);
+}
+
+void CommissioningProxySessionManager::DispatchMessageFailure(uint16_t sessionId, Status status)
+{
+    SessionSlot * slot = FindSlot(sessionId);
+    if (slot == nullptr || slot->pending == nullptr)
+    {
+        return;
+    }
+
+    PendingMessage * pm = slot->pending;
+    slot->pending       = nullptr;
+    mTimerDelegate.CancelTimer(pm);
+
+    if (app::CommandHandler * cmd = pm->handle.Get())
+    {
+        cmd->AddStatus(pm->path, status);
+    }
+    mPendingPool.ReleaseObject(pm);
+}
+
+void CommissioningProxySessionManager::Shutdown()
+{
+    for (auto & slot : mSessions)
+    {
+        if (slot.pending != nullptr)
+        {
+            mTimerDelegate.CancelTimer(slot.pending);
+            mPendingPool.ReleaseObject(slot.pending);
+            slot.pending = nullptr;
+        }
+        slot.inUse = false;
+    }
+}
+
+} // namespace CommissioningProxy
+} // namespace Clusters
+} // namespace app
+} // namespace chip
diff --git a/src/app/clusters/commissioning-proxy-server/CommissioningProxySessionManager.h b/src/app/clusters/commissioning-proxy-server/CommissioningProxySessionManager.h
new file mode 100644
index 0000000..05a1f58
--- /dev/null
+++ b/src/app/clusters/commissioning-proxy-server/CommissioningProxySessionManager.h
@@ -0,0 +1,166 @@
+/*
+ *
+ *    Copyright (c) 2026 Project CHIP Authors
+ *    All rights reserved.
+ *
+ *    Licensed under the Apache License, Version 2.0 (the "License");
+ *    you may not use this file except in compliance with the License.
+ *    You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *    Unless required by applicable law or agreed to in writing, software
+ *    distributed under the License is distributed on an "AS IS" BASIS,
+ *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *    See the License for the specific language governing permissions and
+ *    limitations under the License.
+ */
+
+#pragma once
+
+#include <app/CommandHandler.h>
+#include <app/data-model-provider/OperationTypes.h>
+#include <clusters/CommissioningProxy/Enums.h>
+#include <lib/core/CHIPConfig.h>
+#include <lib/core/CHIPError.h>
+#include <lib/core/DataModelTypes.h>
+#include <lib/support/Pool.h>
+#include <lib/support/TimerDelegate.h>
+#include <protocols/interaction_model/StatusCode.h>
+
+#include <cstdint>
+#include <optional>
+#include <utility>
+
+namespace chip {
+namespace app {
+namespace Clusters {
+namespace CommissioningProxy {
+
+/**
+ * @brief Transport-agnostic proxy-session bookkeeping and ProxyMessage routing.
+ *
+ * Owns everything about a live proxy session that does not depend on the physical
+ * transport: session-id allocation, the id → {transport, fabric} table, per-fabric
+ * isolation checks, the active-session count for the MaxSessions gate, and the
+ * pending ProxyMessageRequest exchange (with its response-timeout timer).
+ *
+ * A platform transport calls Alloc()/Register() when a connection completes,
+ * Remove() on teardown, and DispatchMessageResponse()/DispatchMessageFailure() when
+ * a commissionee reply arrives or the link drops. All calls run on the Matter
+ * thread with the stack lock held.
+ */
+class CommissioningProxySessionManager
+{
+public:
+    struct SessionInfo
+    {
+        CapabilitiesBitmap transport;
+        FabricIndex fabricIndex;
+    };
+
+    CommissioningProxySessionManager() = delete;
+    explicit CommissioningProxySessionManager(TimerDelegate & timerDelegate) : mTimerDelegate(timerDelegate) {}
+    ~CommissioningProxySessionManager() = default;
+
+    // --- Session table ------------------------------------------------------
+
+    /// Allocate the next free session id (never 0, never a currently-active id).
+    uint16_t AllocSessionId();
+
+    /// Record an established session. Idempotent overwrite for a re-registered id.
+    void RegisterSession(uint16_t sessionId, CapabilitiesBitmap transport, FabricIndex fabricIndex);
+
+    /// Drop a session record and cancel any pending message for it. Idempotent.
+    void RemoveSession(uint16_t sessionId);
+
+    /// Look up a session, or std::nullopt if unknown.
+    std::optional<SessionInfo> FindSession(uint16_t sessionId) const;
+
+    /// Id of any one session belonging to @p fabricIndex, or std::nullopt. Call
+    /// repeatedly (removing each) to drain a fabric.
+    std::optional<uint16_t> FindAnySessionIdOnFabric(FabricIndex fabricIndex) const;
+
+    /// Number of established sessions (excludes in-flight connects — the cluster
+    /// adds each transport's IsConnectPending() to this for the MaxSessions gate).
+    uint8_t ActiveCount() const;
+
+    bool IsEmpty() const { return ActiveCount() == 0; }
+
+    // --- ProxyMessageRequest routing ----------------------------------------
+
+    /**
+     * @brief Record a pending ProxyMessageRequest so the commissionee reply can be
+     *        matched back to it, keeping the IM exchange open and arming a
+     *        response-timeout timer. Call this only for a non-zero responseTimeout
+     *        (a zero timeout is a fire-and-forget poll the cluster answers directly).
+     *
+     * @return Busy if a live request is already pending for @p sessionId (an already
+     *         expired one is cleaned up first); Success once the pending state is
+     *         armed. The caller then forwards the bytes to the transport, and calls
+     *         AbortPending() if that send fails.
+     */
+    Protocols::InteractionModel::Status BeginMessage(uint16_t sessionId, app::CommandHandler * commandObj,
+                                                     const DataModel::InvokeRequest & request, uint8_t responseTimeoutSeconds);
+
+    /// Tear down pending state for a session without answering the commissioner
+    /// (used to roll back BeginMessage when the transport send fails).
+    void AbortPending(uint16_t sessionId);
+
+    /// Forward a commissionee reply as a ProxyMessageResponse. No-op if nothing is
+    /// pending for @p sessionId.
+    void DispatchMessageResponse(uint16_t sessionId, const uint8_t * data, size_t length);
+
+    /// Fail a pending ProxyMessageRequest (e.g. session dropped mid-message).
+    void DispatchMessageFailure(uint16_t sessionId, Protocols::InteractionModel::Status status);
+
+    /// Cancel every session and pending message (cluster teardown).
+    void Shutdown();
+
+private:
+    // One in-flight ProxyMessageRequest per session: keeps the IM exchange open until
+    // the commissionee replies and the transport hands the bytes back. Each record is its
+    // own TimerContext so that concurrent sessions each get their own response timeout.
+    struct PendingMessage : public TimerContext
+    {
+        PendingMessage(CommissioningProxySessionManager * aOwner, app::CommandHandler::Handle && aHandle,
+                       const app::ConcreteCommandPath & aPath, uint16_t aSessionId) :
+            owner(aOwner),
+            handle(std::move(aHandle)), path(aPath), sessionId(aSessionId)
+        {}
+
+        CommissioningProxySessionManager * owner;
+        app::CommandHandler::Handle handle;
+        app::ConcreteCommandPath path;
+        uint16_t sessionId;
+
+        void TimerFired() override { owner->OnResponseTimeout(this); }
+    };
+
+    /// Resolve the pending request for @p sessionId with Status::Timeout (called by
+    /// PendingMessage::TimerFired).
+    void OnResponseTimeout(PendingMessage * pm);
+
+    /// One established session. `inUse` false marks a free slot; the table is small
+    /// (MaxSessions) so a linear scan beats any index.
+    struct SessionSlot
+    {
+        bool inUse = false;
+        uint16_t sessionId;
+        SessionInfo info;
+        PendingMessage * pending = nullptr; // at most one in-flight request per session
+    };
+
+    SessionSlot * FindSlot(uint16_t sessionId);
+    const SessionSlot * FindSlot(uint16_t sessionId) const;
+
+    TimerDelegate & mTimerDelegate;
+    SessionSlot mSessions[CHIP_CONFIG_COMMISSIONING_PROXY_MAX_SESSIONS];
+    ObjectPool<PendingMessage, CHIP_CONFIG_COMMISSIONING_PROXY_MAX_SESSIONS> mPendingPool;
+    uint16_t mNextSessionId = 1;
+};
+
+} // namespace CommissioningProxy
+} // namespace Clusters
+} // namespace app
+} // namespace chip
diff --git a/src/app/clusters/commissioning-proxy-server/CommissioningProxyTestEventTriggerHandler.h b/src/app/clusters/commissioning-proxy-server/CommissioningProxyTestEventTriggerHandler.h
new file mode 100644
index 0000000..608b544
--- /dev/null
+++ b/src/app/clusters/commissioning-proxy-server/CommissioningProxyTestEventTriggerHandler.h
@@ -0,0 +1,64 @@
+/*
+ *
+ *    Copyright (c) 2026 Project CHIP Authors
+ *
+ *    Licensed under the Apache License, Version 2.0 (the "License");
+ *    you may not use this file except in compliance with the License.
+ *    You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *    Unless required by applicable law or agreed to in writing, software
+ *    distributed under the License is distributed on an "AS IS" BASIS,
+ *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *    See the License for the specific language governing permissions and
+ *    limitations under the License.
+ */
+
+#pragma once
+
+#include <app-common/zap-generated/cluster-objects.h>
+#include <app/TestEventTriggerDelegate.h>
+
+/**
+ * @brief User handler for handling the test event trigger
+ *
+ * @note If TestEventTrigger is enabled, it needs to be implemented in the app
+ *
+ * @param eventTrigger Event trigger to handle
+ *
+ * @retval true on success
+ * @retval false if error happened
+ */
+bool HandleCommissioningProxyTestEventTrigger(uint64_t eventTrigger);
+
+namespace chip {
+
+/*
+ * These Test EventTrigger values can be used within Commissioning Proxy
+ *
+ * They are sent along with the enableKey (manufacturer defined secret)
+ * in the General Diagnostic cluster TestEventTrigger command
+ */
+enum class CommissioningProxyTrigger : uint64_t
+{
+    kTemplateNotUsed = 0x0455'0000'0000'0000,
+};
+
+class CommissioningProxyTestEventTriggerHandler : public TestEventTriggerHandler
+{
+public:
+    CommissioningProxyTestEventTriggerHandler() {}
+
+    CHIP_ERROR HandleEventTrigger(uint64_t eventTrigger) override
+    {
+        eventTrigger = clearEndpointInEventTrigger(eventTrigger);
+        if (HandleCommissioningProxyTestEventTrigger(eventTrigger))
+        {
+            return CHIP_NO_ERROR;
+        }
+        return CHIP_ERROR_INVALID_ARGUMENT;
+    }
+};
+
+} // namespace chip
diff --git a/src/app/clusters/commissioning-proxy-server/CommissioningProxyTransport.h b/src/app/clusters/commissioning-proxy-server/CommissioningProxyTransport.h
new file mode 100644
index 0000000..0ebce29
--- /dev/null
+++ b/src/app/clusters/commissioning-proxy-server/CommissioningProxyTransport.h
@@ -0,0 +1,189 @@
+/*
+ *
+ *    Copyright (c) 2026 Project CHIP Authors
+ *    All rights reserved.
+ *
+ *    Licensed under the Apache License, Version 2.0 (the "License");
+ *    you may not use this file except in compliance with the License.
+ *    You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *    Unless required by applicable law or agreed to in writing, software
+ *    distributed under the License is distributed on an "AS IS" BASIS,
+ *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *    See the License for the specific language governing permissions and
+ *    limitations under the License.
+ */
+
+#pragma once
+
+#include <app/CommandHandler.h>
+#include <app/data-model-provider/OperationTypes.h>
+#include <clusters/CommissioningProxy/Enums.h>
+#include <lib/core/CHIPError.h>
+#include <lib/core/DataModelTypes.h>
+#include <protocols/interaction_model/StatusCode.h>
+#include <system/SystemClock.h>
+#include <system/SystemPacketBuffer.h>
+
+namespace chip {
+namespace app {
+namespace Clusters {
+namespace CommissioningProxy {
+
+class CommissioningProxyCluster;
+
+/**
+ * @brief Platform transport driver for the Commissioning Proxy cluster.
+ *
+ * This is the ONLY piece an application/platform must supply. Everything that is
+ * transport-agnostic — command validation, session-id allocation and the session
+ * table, per-fabric isolation, ProxyMessageRequest/Response routing, the
+ * background-scan result cache, and multi-transport ProxyScanRequest aggregation —
+ * lives in the cluster and its subsystems (see CommissioningProxySessionManager,
+ * CommissioningProxyScanCache, CommissioningProxyScanAggregator).
+ *
+ * One concrete implementation exists per physical transport (BLE, Wi-Fi PAF). The
+ * cluster owns a small set of registered transports and dispatches to the one that
+ * matches a request's Transport bit. This interface is the *union* of what the BLE
+ * and Wi-Fi PAF drivers need; a driver that has no concept of a given parameter
+ * (e.g. BLE has no Wi-Fi band) simply ignores it.
+ *
+ * Call direction:
+ *   - cluster → transport: the methods below (Connect, Scan, SendMessage, ...).
+ *   - transport → cluster: the driver reports asynchronous results back through the
+ *     host cluster it was given in SetHost(), by calling the cluster's subsystem
+ *     accessors — Sessions() for session/message bookkeeping, ScanCache() for
+ *     background-scan results, and ScanAggregator() for foreground-scan results.
+ *
+ * Threading: every method is invoked with the Matter stack lock held, and every
+ * callback into the host must likewise run on the Matter thread.
+ */
+class CommissioningProxyTransport
+{
+public:
+    virtual ~CommissioningProxyTransport() = default;
+
+    /// The single Transport bit this driver services (kBle or kWiFiPAF).
+    virtual CapabilitiesBitmap GetTransportType() const = 0;
+
+    /**
+     * @brief Bind the driver to its host cluster.
+     *
+     * Called once when the transport is registered with the cluster, and again with
+     * nullptr when the cluster is torn down so the driver never dereferences a
+     * dangling cluster pointer. The driver reports async results back through this
+     * pointer's subsystem accessors.
+     */
+    virtual void SetHost(CommissioningProxyCluster * cluster) = 0;
+
+    // --- ProxyConnectRequest ------------------------------------------------
+
+    /**
+     * @brief Open a transport connection to a commissionable device.
+     *
+     * On success the driver allocates a session via the host's Sessions() manager,
+     * registers it, and calls commandObj->AddResponse() with a ProxyConnectResponse
+     * carrying the sessionID (which may be asynchronous). The cluster has already
+     * validated the request (single supported transport, discriminator range,
+     * WiFiBand vs WI feature, MaxSessions gate) before this is called.
+     */
+    virtual Protocols::InteractionModel::Status Connect(app::CommandHandler * commandObj, const DataModel::InvokeRequest & request,
+                                                        uint16_t discriminator, System::Clock::Seconds16 timeout) = 0;
+
+    /**
+     * @brief Cancel an in-flight Connect for the given fabric (null-SessionID
+     *        ProxyDisconnectRequest). Fails the pending IM exchange.
+     * @return InvalidInState if no connect is pending.
+     */
+    virtual Protocols::InteractionModel::Status CancelPendingConnect(FabricIndex fabricIndex) = 0;
+
+    // --- ProxyDisconnectRequest / ProxyMessageRequest -----------------------
+
+    /// Tear down an established proxy session and release its transport resources.
+    virtual Protocols::InteractionModel::Status Disconnect(uint16_t sessionId) = 0;
+
+    /**
+     * @brief Forward a Matter packet to the commissionee.
+     *
+     * The commissionee reply arrives asynchronously; the driver routes it back via
+     * host->Sessions().DispatchMessageResponse(). A mid-flight session drop is
+     * reported via DispatchMessageFailure().
+     */
+    virtual CHIP_ERROR SendMessage(uint16_t sessionId, System::PacketBufferHandle && buf) = 0;
+
+    // --- ProxyScanRequest (foreground) --------------------------------------
+
+    /**
+     * @brief Start a foreground scan bounded by @p scanMaxTime.
+     *
+     * When the scan completes the driver reports its results to the host's
+     * ScanAggregator() (empty is valid). The aggregator owns the command handle and
+     * emits the single combined ProxyScanResponse once every started transport has
+     * contributed.
+     */
+    virtual Protocols::InteractionModel::Status Scan(System::Clock::Seconds16 scanMaxTime) = 0;
+
+    // --- ProxyBackgroundScanStart / Stop ------------------------------------
+
+    /**
+     * @brief Start/stop a continuous background scan.
+     *
+     * Discovered devices are reported to the host's ScanCache() (which owns TTL,
+     * MaxCachedResults, and CachedResults/NumCachedResults reporting). @p wiFiBands
+     * is ignored by drivers with no band concept (BLE).
+     */
+    virtual Protocols::InteractionModel::Status BgScanStart(System::Clock::Seconds16 timeout, BitMask<WiFiBandBitmap> wiFiBands,
+                                                            FabricIndex fabricIndex, NodeId nodeId) = 0;
+
+    /**
+     * @brief Stop a background scan. The cluster fans the request's full @p transport
+     * mask (and @p wiFiBands) to every registered driver; each matches against its
+     * own per-fabric record and returns NotFound if nothing matched, so a driver may
+     * partially stop (e.g. one band of several). The command reports NotFound only
+     * when no driver matched at all. A band-only request (@p transport == 0) is still
+     * offered to every driver.
+     */
+    virtual Protocols::InteractionModel::Status BgScanStop(BitMask<CapabilitiesBitmap> transport, BitMask<WiFiBandBitmap> wiFiBands,
+                                                           FabricIndex fabricIndex, NodeId nodeId) = 0;
+
+    // --- Lifecycle ----------------------------------------------------------
+
+    /**
+     * @brief Notified by the cluster when the last proxy session across ALL
+     *        transports has closed. A driver may resume state it paused for a
+     *        connect (PAF resumes a paused background scan; BLE resumes its own
+     *        peripheral advertising). Default: no-op.
+     */
+    virtual void OnAllSessionsClosed() {}
+
+    /**
+     * @brief A fabric has been removed. Drop every background-scan request it owns
+     *        (registry.RemoveFabric(fabricIndex)); the cluster has already dealt with
+     *        its sessions and any in-flight connect. FabricIndex values are reused, so
+     *        anything left behind would be inherited by the next fabric to take this
+     *        index. Default: no-op, for drivers with no background-scan state.
+     */
+    virtual void OnFabricRemoved(FabricIndex fabricIndex) {}
+
+    /**
+     * @brief True while a connect is in flight (exchange open, awaiting
+     *        success/error/timeout). The cluster sums this across transports and
+     *        adds it to Sessions().ActiveCount() for the MaxSessions gate, so two
+     *        concurrent connect attempts cannot both pass.
+     */
+    virtual bool IsConnectPending() const = 0;
+
+    /**
+     * @brief Cancel all outstanding driver state (pending connect, per-fabric
+     *        background-scan timers) and stop the hardware scan. Called before the
+     *        cluster is destroyed so no timer outlives it. SetHost(nullptr) follows.
+     */
+    virtual void Shutdown() = 0;
+};
+
+} // namespace CommissioningProxy
+} // namespace Clusters
+} // namespace app
+} // namespace chip
diff --git a/src/app/clusters/commissioning-proxy-server/README.md b/src/app/clusters/commissioning-proxy-server/README.md
new file mode 100644
index 0000000..0c55da3
--- /dev/null
+++ b/src/app/clusters/commissioning-proxy-server/README.md
@@ -0,0 +1,430 @@
+# Commissioning Proxy Cluster
+
+The Commissioning Proxy cluster (cluster ID 0x0455) provides a proxy service
+that allows a Commissioner to use commissioning transports not supported
+locally, or to extend its commissioning range. Commissioners can use the proxy
+to discover and establish a connection to commissionable devices that are
+reachable by the proxy. The proxy connection acts as a tunnel through which the
+Commissioner can run a PASE session with the commissionable device and
+ultimately commission it.
+
+## Overview
+
+This directory contains an implementation of the Matter Commissioning Proxy
+cluster server (`CommissioningProxyCluster.{h,cpp}`) using the code-driven
+`DefaultServerCluster` pattern.
+
+The cluster owns **all transport-agnostic behavior**: command validation,
+feature-based attribute/command gating, transport/WiFiBand constraint checks,
+the writable-attribute storage and change-reporting, and — via three subsystems
+it composes — proxy-session bookkeeping, `ProxyMessage` routing, the
+background-scan result cache, and multi-transport `ProxyScanRequest`
+aggregation:
+
+| Subsystem                          | Responsibility                                                                                                            |
+| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
+| `CommissioningProxySessionManager` | Session-id allocation, id→{transport,fabric} table, per-fabric isolation, `ProxyMessage` routing + response-timeout timer |
+| `CommissioningProxyScanCache`      | `CachedResults` / `NumCachedResults` (one entry per device, per-entry TTL, `MaxCachedResults` cap)                        |
+| `CommissioningProxyScanAggregator` | Combines a multi-transport `ProxyScanRequest` into one `ProxyScanResponse`                                                |
+
+A fourth transport-agnostic component, `CommissioningProxyBgScanRegistry`, ships
+with the cluster but is **instantiated per transport and owned by the driver**
+rather than composed by the cluster:
+`ProxyBackGroundScanStartRequest`/`ProxyBackGroundScanStopRequest` fan out to
+every matching driver, so the per-fabric records they arbitrate are necessarily
+transport-local. It holds the per-fabric scan requests and their lifetime
+timers, the spec transport/band overlap arithmetic on Stop, and the
+paused/deferred state used while the radio is held by a connect or foreground
+scan — leaving the driver only the hardware start/stop. See
+[Background scanning](#background-scanning).
+
+The application supplies **only the platform-specific transport work** by
+implementing the `CommissioningProxyTransport` driver interface (one per
+physical transport, e.g. BLE or Wi-Fi PAF) and registering it with the cluster.
+There is no do-everything application delegate: static device capabilities are
+passed to the constructor via `Config`, writable-attribute state is owned by the
+cluster, and the driver is scoped to transport actions only. See
+[Architecture](#architecture-why-a-driver-not-a-delegate) below.
+
+### How it works
+
+A Commissioner that lacks (or is out of range of) the transport a commissionable
+device advertises on uses the proxy as a tunnel. The flow is:
+
+1. **Discover** — the Commissioner sends `ProxyScanRequest` (or subscribes to
+   the background-scan cache); the proxy scans its local transports and returns
+   the commissionable devices it can see as `ScanResultStruct` entries.
+2. **Connect** — the Commissioner sends `ProxyConnectRequest` naming a single
+   transport plus the target device (address/discriminator/VID/PID). The proxy
+   opens a transport connection to the device and replies with a unique
+   `SessionID` in the `ProxyConnectResponse`.
+3. **Tunnel** — the Commissioner drives the PASE handshake (and the rest of
+   commissioning) by sending each Matter packet in a `ProxyMessageRequest` keyed
+   by `SessionID`; the proxy forwards it over the transport and returns the
+   device's reply in the `ProxyMessageResponse`. The proxy is a dumb relay — the
+   PASE session is end-to-end between the Commissioner and the device.
+4. **Disconnect** — the Commissioner sends `ProxyDisconnectRequest` to cancel an
+   in-flight connect); the proxy tears the transport connection down.
+
+The cluster server itself is **transport-agnostic**: it validates the requested
+transport against the set it advertises, then dispatches the work to the
+registered `CommissioningProxyTransport` driver whose `GetTransportType()`
+matches the request's transport bit. Today drivers exist for **BLE** (BTP) and
+**Wi-Fi PAF** (PAFTP) — see the transport integration sections below.
+
+## Features
+
+The cluster supports the following optional features:
+
+-   **WiFiNetworkInterface (WI)**: Gates the Wi-Fi-band surface — the `WiFiBand`
+    attribute and the `WiFiBand(s)` command fields. It does **not** gate the
+    Wi-Fi PAF transport itself (see Transports below).
+-   **BackgroundScan (BGS)**: Allows background scanning of commissioning
+    transports, with results cached in the `CachedResults` / `NumCachedResults`
+    attributes.
+
+## Transports
+
+The transports the proxy advertises in the `Transport` attribute (and accepts in
+`ProxyConnectRequest` / `ProxyScanRequest`) are exactly the ones for which a
+driver has been registered (`RegisterTransport`), independent of the `WI`
+feature. An application typically registers a driver per compiled-in transport:
+
+| Transport (`CapabilitiesBitmap`) | Driver registered when              | Wire protocol        |
+| -------------------------------- | ----------------------------------- | -------------------- |
+| `kBle` (BTP)                     | `CONFIG_NETWORK_LAYER_BLE`          | BTP over BLE         |
+| `kWiFiPAF` (PAFTP)               | `CHIP_DEVICE_CONFIG_ENABLE_WIFIPAF` | PAFTP over Wi-Fi NAN |
+
+`ProxyConnectRequest` selects **exactly one** transport per session. A request
+naming a transport with no registered driver is rejected with
+`INVALID_TRANSPORT_TYPE`. (The spec's `NTL` bit is not currently supported.)
+
+## Architecture: why a driver, not a delegate
+
+Per the SDK cluster guidance, a cluster that triggers platform-specific actions
+uses a **Delegate (or Driver) interface**, while a cluster's own attribute state
+is pushed through setters rather than fetched from a delegate. This cluster
+follows that split precisely, so instead of one do-everything delegate there are
+four homes for what such a delegate would otherwise hold:
+
+| Concern                                                           | Lives in                                            | Why                                                                                                              |
+| ----------------------------------------------------------------- | --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
+| Transport actions (Connect / Scan / SendMessage / Disconnect / …) | `CommissioningProxyTransport` driver                | The only genuinely platform-specific surface (BlueZ GATT, wpa_supplicant NAN).                                   |
+| Writable attributes (`ScanMaxTime`, `CacheTimeout`) + cache view  | The cluster (members + setters) and its subsystems  | The cluster owns change-reporting, so a driver can never forget `NotifyAttributeChanged`.                        |
+| Static capabilities (`MaxSessions`, `MaxCachedResults`, bands)    | `Config` (constructor argument)                     | Fixed device facts, not actions.                                                                                 |
+| Background-scan fabric records, band arithmetic, pause/resume     | `CommissioningProxyBgScanRegistry` (one per driver) | Transport-agnostic logic, but held per transport because background Start/Stop fan out to every matching driver. |
+
+**How multiple platforms plug in.** A platform provides one
+`CommissioningProxyTransport` implementation per physical transport and
+registers it with `RegisterTransport()`. The cluster dispatches each command to
+the driver whose `GetTransportType()` matches the request's transport bit, and
+drivers report async results back through the cluster's shared subsystems
+(`Sessions()`, `ScanCache()`, `ScanAggregator()`). Background scanning is
+inherited the same way: the driver holds a `CommissioningProxyBgScanRegistry`
+and implements its three-method `HardwareControl` hook. A new platform therefore
+writes only its GATT/NAN driver and inherits all session/scan/message
+bookkeeping unchanged.
+
+`RegisterTransport()` is used (rather than a single constructor-injected
+delegate) because there can be several drivers — one per transport — and they
+are typically constructed after the cluster, whose endpoint is only known at
+registration time.
+
+## Usage
+
+For new applications using the `CodeDrivenDataModelProvider`, instantiate and
+register the cluster directly. This provides the most flexibility and control.
+
+### 1. Implement a transport driver
+
+Create a class that inherits from
+`chip::app::Clusters::CommissioningProxy::CommissioningProxyTransport` and
+implement its virtual methods — one class per physical transport. A driver only
+handles the transport-specific work; the cluster performs all spec validation
+first and owns the session/scan/message bookkeeping. Methods run on the Matter
+task; the driver reports async results back through its host cluster (given in
+`SetHost`) via `Sessions()`, `ScanCache()`, and `ScanAggregator()`.
+
+```cpp
+#include <app/clusters/commissioning-proxy-server/CommissioningProxyTransport.h>
+
+class MyBleTransport : public chip::app::Clusters::CommissioningProxy::CommissioningProxyTransport
+{
+public:
+    CapabilitiesBitmap GetTransportType() const override { return CapabilitiesBitmap::kBle; }
+    void SetHost(CommissioningProxyCluster * cluster) override { mHost = cluster; }
+
+    // Open a transport connection. On success, allocate a session via
+    // mHost->Sessions().AllocSessionId()/RegisterSession() and call
+    // commandObj->AddResponse() with a ProxyConnectResponse carrying the sessionID.
+    Protocols::InteractionModel::Status Connect(chip::app::CommandHandler * commandObj,
+                                                const DataModel::InvokeRequest & request, uint16_t discriminator,
+                                                System::Clock::Seconds16 timeout) override;
+
+    // Forward a Matter packet; deliver the reply via
+    // mHost->Sessions().DispatchMessageResponse().
+    CHIP_ERROR SendMessage(uint16_t sessionId, chip::System::PacketBufferHandle && buf) override;
+
+    // Report foreground-scan results to mHost->ScanAggregator().Contribute();
+    // background-scan results to mHost->ScanCache().Report().
+    Protocols::InteractionModel::Status Scan(System::Clock::Seconds16 scanMaxTime) override;
+
+    // Background scan: forward to the driver's CommissioningProxyBgScanRegistry,
+    // which owns the per-fabric records, lifetime timers and band arithmetic
+    // (see Background scanning below).
+    Protocols::InteractionModel::Status BgScanStart(System::Clock::Seconds16 timeout, BitMask<WiFiBandBitmap> wiFiBands,
+                                                    FabricIndex fabricIndex, NodeId nodeId) override;
+    Protocols::InteractionModel::Status BgScanStop(BitMask<CapabilitiesBitmap> transport, BitMask<WiFiBandBitmap> wiFiBands,
+                                                   FabricIndex fabricIndex, NodeId nodeId) override;
+
+    // ... CancelPendingConnect / Disconnect / OnAllSessionsClosed /
+    //     IsConnectPending / Shutdown
+
+private:
+    CommissioningProxyCluster * mHost = nullptr;
+};
+```
+
+### 2. Instantiate the cluster and register the driver(s)
+
+Construct the `CommissioningProxyCluster` for the endpoint (the endpoint id is a
+constructor argument; `Config` carries the fixed feature set and device
+capabilities), then register a driver per available transport.
+
+```cpp
+#include <app/clusters/commissioning-proxy-server/CommissioningProxyCluster.h>
+#include <app/server-cluster/ServerClusterInterfaceRegistry.h>
+
+using namespace chip::app::Clusters::CommissioningProxy;
+
+MyBleTransport gBleTransport;
+
+chip::BitMask<Feature> gFeatures(Feature::kBackgroundScan, Feature::kWiFiNetworkInterface);
+
+// Supplies the response-timeout and scan-watchdog timers.
+chip::app::DefaultTimerDelegate gTimerDelegate;
+
+// Config: featureFlags plus the supported Wi-Fi bands. MaxSessions and
+// MaxCachedResults are Fixed-quality attributes and come from
+// CHIP_CONFIG_COMMISSIONING_PROXY_MAX_SESSIONS / _MAX_CACHED_RESULTS.
+chip::app::RegisteredServerCluster<CommissioningProxyCluster> gCPCluster(
+    CommissioningProxyEndpoint, CommissioningProxyCluster::Config(gFeatures), gTimerDelegate);
+
+void SetUpProxy()
+{
+    gCPCluster.Cluster().RegisterTransport(gBleTransport); // before registration/Startup
+}
+```
+
+### 3. Register the Cluster
+
+In your application's initialization sequence, register the cluster instance
+with the `CodegenDataModelProvider`:
+
+```cpp
+#include <data-model-providers/codegen/CodegenDataModelProvider.h>
+
+void ApplicationInit()
+{
+    VerifyOrDie(chip::app::CodegenDataModelProvider::Instance().Registry().Register(
+        gCPCluster.Registration()) == CHIP_NO_ERROR);
+}
+```
+
+A complete working example (device wiring plus the BLE and Wi-Fi PAF drivers)
+lands with the example-app change later in this series.
+
+## Transport driver methods
+
+A `CommissioningProxyTransport` driver implements the following. Each is invoked
+only after the cluster has validated the request; the driver does the
+transport-specific work and reports results back through the host cluster's
+subsystems.
+
+| Method                   | Description                                                                                                                                                                            |
+| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `GetTransportType()`     | The single transport bit this driver services (`kBle` / `kWiFiPAF`)                                                                                                                    |
+| `SetHost()`              | Bind the host cluster (set to null at cluster teardown)                                                                                                                                |
+| `Connect()`              | Open a transport session; allocate + register it via `Sessions()`                                                                                                                      |
+| `SendMessage()`          | Forward a packet; reply routed back via `Sessions()`                                                                                                                                   |
+| `Scan()`                 | Foreground scan; results reported to `ScanAggregator()`                                                                                                                                |
+| `BgScanStart()`          | Start a background scan for the given `(fabricIndex, nodeId)`; normally forwarded to the driver's `CommissioningProxyBgScanRegistry::Start()`. Results reported to `ScanCache()` (BGS) |
+| `BgScanStop()`           | Stop a background scan for the given `(fabricIndex, nodeId)`; normally forwarded to `CommissioningProxyBgScanRegistry::Stop()` (BGS)                                                   |
+| `CancelPendingConnect()` | Cancel an in-flight connect (null-SessionID disconnect)                                                                                                                                |
+| `Disconnect()`           | Tear down an active proxy session                                                                                                                                                      |
+| `OnAllSessionsClosed()`  | Notified when the last session across all transports closes                                                                                                                            |
+| `IsConnectPending()`     | Whether a connect is in flight (counted against `MaxSessions`)                                                                                                                         |
+| `Shutdown()`             | Cancel driver timers/state before cluster destruction                                                                                                                                  |
+
+Note there is **no** `ProxyScanRequest`/`ProxyMessageRequest`/etc. delegate
+hook: those commands' spec logic, session tracking, message routing, and scan
+aggregation live in the cluster and its subsystems; the driver only exposes the
+transport primitives above.
+
+## Background scanning
+
+`ProxyBackGroundScanStartRequest`/`ProxyBackGroundScanStopRequest` are
+per-fabric and MAY name several transports, so the cluster validates the request
+(BGS/WI feature gating, reserved transport and band bits, supported bands) and
+then fans it out to every registered driver whose transport bit is set, passing
+the requesting fabric index and node id taken from the subject descriptor. Start
+reports the first non-success status a driver returned; Stop returns `NOT_FOUND`
+only when no driver had a matching record.
+
+Everything a driver then has to do that is _not_ radio work is provided by
+`CommissioningProxyBgScanRegistry` — one instance per driver, constructed with a
+reference to the driver's `HardwareControl` implementation:
+
+| Registry call      | Responsibility                                                                                                                                      |
+| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `Start()`          | Add or refresh the `(fabricIndex, nodeId)` record and its transport/band mask, arm its lifetime timer, start the hardware if it is the first record |
+| `Stop()`           | Remove the requested transports/bands from the record; stop the hardware once no records remain                                                     |
+| `Pause()`          | Suspend the hardware scan while the radio is needed for a connect or foreground scan; records stay registered (idempotent)                          |
+| `ResumeIfNeeded()` | Restart a paused scan once the radio is free; no-op if not paused or if no records remain                                                           |
+| `Shutdown()`       | Cancel every lifetime timer and stop the hardware scan if the registry owns it                                                                      |
+
+`Start()` with `timeoutSecs == 0` means no lifetime timer, i.e. scan until an
+explicit Stop. `Stop()` applies the spec's transport/band arithmetic: a zero
+transport mask means "stop the listed bands only"; a record left with no
+transports or no bands is removed; `SUCCESS` is returned even when nothing
+overlapped, and `NOT_FOUND` only when the fabric has no record at all.
+
+The driver supplies the only transport-specific parts via `HardwareControl`:
+
+| Hook                   | Contract                                                                                                           |
+| ---------------------- | ------------------------------------------------------------------------------------------------------------------ |
+| `StartHardwareScan()`  | Start or resume the hardware scan, wiring the driver's own discovery callback (return codes below)                 |
+| `StopHardwareScan()`   | Stop the hardware scan; called only while the registry owns the radio, never while paused                          |
+| `ClearCachedResults()` | Drop this transport's cached results (`host->ScanCache().ClearTransport(...)`) whenever the last record is removed |
+
+`StartHardwareScan()` returns `CHIP_NO_ERROR` when the scan is running,
+`CHIP_ERROR_BUSY` when the radio is currently held — the registry keeps the
+records, stays paused and retries on the next `ResumeIfNeeded()` — and any other
+error is a hard failure that rejects the triggering `Start()`.
+
+A driver therefore wires background scanning up as a small `HardwareControl`
+implementation plus one registry instance, and forwards the two driver methods
+to it:
+
+```cpp
+#include <app/clusters/commissioning-proxy-server/CommissioningProxyBgScanRegistry.h>
+
+class MyBleBgScanHardware : public CommissioningProxyBgScanRegistry::HardwareControl
+{
+public:
+    // Returns CHIP_ERROR_BUSY when the single scanner is held by a connect or a
+    // foreground scan; the registry then defers and retries on resume.
+    CHIP_ERROR StartHardwareScan() override { return StartMyPlatformScan(OnBgScanDiscovery); }
+    void StopHardwareScan() override { StopMyPlatformScan(); }
+    void ClearCachedResults() override { sHost->ScanCache().ClearTransport(CapabilitiesBitmap::kBle); }
+};
+
+// Declared before the registry so it outlives it: the registry's destructor may
+// call back into these hooks.
+MyBleBgScanHardware sHardware;
+CommissioningProxyBgScanRegistry sBgScan(sHardware);
+
+Status MyBleTransport::BgScanStart(System::Clock::Seconds16 timeout, BitMask<WiFiBandBitmap> wiFiBands, FabricIndex fabricIndex,
+                                   NodeId nodeId)
+{
+    return sBgScan.Start(fabricIndex, nodeId, GetTransportType(), wiFiBands, timeout);
+}
+
+Status MyBleTransport::BgScanStop(BitMask<CapabilitiesBitmap> transport, BitMask<WiFiBandBitmap> wiFiBands,
+                                 FabricIndex fabricIndex, NodeId nodeId)
+{
+    return sBgScan.Stop(fabricIndex, nodeId, transport, wiFiBands);
+}
+```
+
+`CHIP_ERROR_BUSY` is the load-bearing case: on BLE the scanner is owned by a
+connect or foreground scan, and on Wi-Fi PAF a `ProxyConnect` owns the single
+NAN subscribe slot. A driver reports the conflict from `StartHardwareScan()`,
+calls `Pause()` when it takes the radio and `ResumeIfNeeded()` when it releases
+it, and the registry keeps the Commissioner's background scan registered across
+the gap. `ResumeIfNeeded()` must be wrapped in `PlatformMgr().ScheduleWork()` if
+the "radio freed" path could otherwise re-enter the driver.
+
+## Async Command Handling
+
+`ProxyConnectRequest` and `ProxyMessageRequest` are asynchronous — they return
+`std::nullopt` from `InvokeCommand` to prevent an immediate response, and call
+`CommandHandler::AddResponse()` or `CommandHandler::AddStatus()` from a
+transport callback once the operation completes.
+
+To keep the exchange alive across the async operation, store a
+`CommandHandler::Handle` and extend the exchange response timeout:
+
+```cpp
+// Store the handle before returning nullopt
+CommandHandler::Handle handle(commandObj);
+if (auto * ec = commandObj->GetExchangeContext())
+{
+    ec->SetResponseTimeout(chip::System::Clock::Seconds16(responseTimeout + 10));
+}
+// … return std::nullopt from InvokeCommand …
+
+// Later, in your transport callback:
+auto * handler = handle.Get();
+if (handler != nullptr)
+{
+    handler->AddResponse(commandPath, response);
+}
+```
+
+## BLE Transport Integration
+
+When the build enables BLE (`CONFIG_NETWORK_LAYER_BLE`) the driver
+(`CommissioningProxyBleTransport`) drives a BTP connection through
+`chip::Ble::BleLayer`. The Linux example driver, landing with the example-app
+change later in this series,
+(`examples/all-devices-app/all-devices-common/device/types/commissioning-proxy/CommissioningProxyBleTransport.cpp`)
+shows the full integration:
+
+-   `Connect()` — on the first BLE connect the proxy flips its own BLE role from
+    peripheral to central via `BLEManagerImpl::SwitchToCentralMode()` (a one-way
+    switch; `IsCentralMode()` reports the state and central-mode advertising is
+    then refused), then calls `BleLayer::NewBleConnectionByDiscriminator()` to
+    open an L2CAP/BTP connection to the commissionee.
+-   `SendMessage()` — calls `BLEEndPoint::Send()` to push the tunneled
+    commissioning packet over BTP.
+-   `Disconnect()` — calls `BLEEndPoint::Close()` to drop the connection.
+
+Incoming BTP messages are routed back to the cluster via a `BleProxyDelegate`
+(`chip::Ble::BleLayerDelegate`) that wraps the original `BleLayer` transport,
+matches the connection against the active session map, and calls
+`host->Sessions().DispatchMessageResponse()`.
+
+## Wi-Fi PAF Transport Integration
+
+When the build enables Wi-Fi PAF (`CHIP_DEVICE_CONFIG_ENABLE_WIFIPAF`) the
+driver (`CommissioningProxyPafTransport`) interacts with
+`chip::WiFiPAF::WiFiPAFLayer` to open, send over, receive from, and close PAF
+(NAN) sessions:
+
+-   `Connect()` — calls `WiFiPAFLayer::WiFiPAFSubscribe()` to open a PAF session
+    identified by the commissionee discriminator and peer address.
+-   `SendMessage()` — calls `WiFiPAFLayer::SendMessage()` to send the tunneled
+    commissioning packet over PAFTP.
+-   `Disconnect()` — calls `WiFiPAFLayer::RmPafSession()` to release the PAF
+    session.
+
+Incoming PAF messages are routed back to the cluster via a
+`WiFiPAFLayerDelegate` subclass that intercepts `WiFiPAFMessageReceived`,
+matches the peer against the active session map, and calls
+`host->Sessions().DispatchMessageResponse()`.
+
+## Cluster State
+
+The cluster tracks proxy state internally:
+
+-   `kState_CPDisconnected` — no active proxy session
+-   `kState_CPConnected` — a transport session (BLE or Wi-Fi PAF) is open and
+    ready to forward messages
+
+State transitions:
+
+```
+ProxyConnectRequest ──► transport connect success ──► kState_CPConnected
+kState_CPConnected  ──► ProxyDisconnectRequest    ──► kState_CPDisconnected
+```
diff --git a/src/app/clusters/commissioning-proxy-server/app_config_dependent_sources.cmake b/src/app/clusters/commissioning-proxy-server/app_config_dependent_sources.cmake
new file mode 100644
index 0000000..830dae4
--- /dev/null
+++ b/src/app/clusters/commissioning-proxy-server/app_config_dependent_sources.cmake
@@ -0,0 +1,22 @@
+# Copyright (c) 2026 Project CHIP Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# This is the equivalent to app_config_dependent_sources.gni
+TARGET_SOURCES(
+  ${APP_TARGET}
+  PRIVATE
+    "${CLUSTER_DIR}/CodegenIntegration.cpp"
+    "${CLUSTER_DIR}/CodegenIntegration.h"
+    "${CLUSTER_DIR}/CommissioningProxyTestEventTriggerHandler.h"
+)
\ No newline at end of file
diff --git a/src/app/clusters/commissioning-proxy-server/app_config_dependent_sources.gni b/src/app/clusters/commissioning-proxy-server/app_config_dependent_sources.gni
new file mode 100644
index 0000000..9e78d8b
--- /dev/null
+++ b/src/app/clusters/commissioning-proxy-server/app_config_dependent_sources.gni
@@ -0,0 +1,18 @@
+# Copyright (c) 2026 Project CHIP Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+app_config_dependent_sources = [
+  "CodegenIntegration.cpp",
+  "CodegenIntegration.h",
+  "CommissioningProxyTestEventTriggerHandler.h",
+]
diff --git a/src/app/clusters/commissioning-proxy-server/tests/BUILD.gn b/src/app/clusters/commissioning-proxy-server/tests/BUILD.gn
new file mode 100644
index 0000000..70932d3
--- /dev/null
+++ b/src/app/clusters/commissioning-proxy-server/tests/BUILD.gn
@@ -0,0 +1,54 @@
+# Copyright (c) 2026 Project CHIP Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import("//build_overrides/build.gni")
+import("//build_overrides/chip.gni")
+import("//build_overrides/pigweed.gni")
+
+import("${chip_root}/build/chip/chip_test_suite.gni")
+
+chip_test_suite("tests") {
+  output_name = "TestCommissioningProxyCluster"
+
+  test_sources = [
+    "TestCommissioningProxyBgScanRegistry.cpp",
+    "TestCommissioningProxyCluster.cpp",
+  ]
+
+  # CodegenIntegration.cpp holds only the empty ember init/shutdown callbacks that
+  # an app's ZAP-generated codedriven-init-shutdown.cpp calls. There is no behaviour
+  # to assert, but compiling it here keeps it building in a PR that ships no app.
+  sources = [
+    "../CodegenIntegration.cpp",
+    "../CodegenIntegration.h",
+    "CommissioningProxyMockTimer.h",
+    "CommissioningProxyMockTransport.cpp",
+    "CommissioningProxyMockTransport.h",
+  ]
+
+  cflags = [ "-Wconversion" ]
+
+  # The transports the cluster advertises come from the drivers registered at
+  # runtime, so no transport build flags need forcing here.  Tests read
+  # GetSupportedTransports() and skip transport-specific cases when the
+  # corresponding bit is absent.
+
+  public_deps = [
+    "${chip_root}/src/app/clusters/commissioning-proxy-server",
+    "${chip_root}/src/app/data-model-provider/tests:encode-decode",
+    "${chip_root}/src/app/server-cluster/testing",
+    "${chip_root}/src/lib/support",
+    "${chip_root}/src/lib/support:timer-delegate-mock",
+  ]
+}
diff --git a/src/app/clusters/commissioning-proxy-server/tests/CommissioningProxyMockTimer.h b/src/app/clusters/commissioning-proxy-server/tests/CommissioningProxyMockTimer.h
new file mode 100644
index 0000000..b5fe5d0
--- /dev/null
+++ b/src/app/clusters/commissioning-proxy-server/tests/CommissioningProxyMockTimer.h
@@ -0,0 +1,138 @@
+/*
+ *
+ *    Copyright (c) 2026 Project CHIP Authors
+ *    All rights reserved.
+ *
+ *    Licensed under the Apache License, Version 2.0 (the "License");
+ *    you may not use this file except in compliance with the License.
+ *    You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *    Unless required by applicable law or agreed to in writing, software
+ *    distributed under the License is distributed on an "AS IS" BASIS,
+ *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *    See the License for the specific language governing permissions and
+ *    limitations under the License.
+ */
+
+#pragma once
+
+#include <lib/core/CHIPError.h>
+#include <lib/support/CodeUtils.h>
+#include <lib/support/TimerDelegate.h>
+#include <system/SystemClock.h>
+
+#include <cstddef>
+
+namespace chip {
+namespace app {
+namespace Clusters {
+namespace CommissioningProxy {
+
+/**
+ * @brief Test TimerDelegate with virtual time, several concurrent timers and
+ *        StartTimer failure injection — none of which chip::TimerDelegateMock offers.
+ *
+ * AdvanceClock() fires timers synchronously, so expiry costs no wall-clock time.
+ */
+class CommissioningProxyMockTimer : public TimerDelegate
+{
+public:
+    static constexpr size_t kMaxTimers = 8;
+
+    CriticalFailure StartTimer(TimerContext * context, System::Clock::Timeout aTimeout) override
+    {
+        if (mNextStartResult != CHIP_NO_ERROR)
+        {
+            // One-shot: the caller under test sees this failure, later timers succeed.
+            CHIP_ERROR err   = mNextStartResult;
+            mNextStartResult = CHIP_NO_ERROR;
+            return err;
+        }
+
+        CancelTimer(context); // re-arming replaces any existing timer for this context
+        // Die rather than return an error: an overflow here is indistinguishable from
+        // FailNextStart(), so a test needing more timers must raise kMaxTimers.
+        VerifyOrDie(mCount < kMaxTimers);
+        mTimers[mCount].context = context;
+        mTimers[mCount].firesAt = mNow + aTimeout;
+        mCount++;
+        return CHIP_NO_ERROR;
+    }
+
+    void CancelTimer(TimerContext * context) override
+    {
+        for (size_t i = 0; i < mCount; i++)
+        {
+            if (mTimers[i].context == context)
+            {
+                mTimers[i] = mTimers[mCount - 1];
+                mCount--;
+                return;
+            }
+        }
+    }
+
+    bool IsTimerActive(TimerContext * context) override
+    {
+        for (size_t i = 0; i < mCount; i++)
+        {
+            if (mTimers[i].context == context)
+            {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    System::Clock::Timestamp GetCurrentMonotonicTimestamp() override { return mNow; }
+
+    // --- Test control ----------------------------------------------------------
+
+    /// Make the next StartTimer() call fail with @p err (one-shot).
+    void FailNextStart(CHIP_ERROR err = CHIP_ERROR_NO_MEMORY) { mNextStartResult = err; }
+
+    /// Move virtual time forward, firing every timer that becomes due. Firing a timer
+    /// removes it first, so a callback may re-arm its own context safely.
+    void AdvanceClock(System::Clock::Timeout aTimeout)
+    {
+        mNow += aTimeout;
+        bool fired = true;
+        while (fired)
+        {
+            fired = false;
+            for (size_t i = 0; i < mCount; i++)
+            {
+                if (mTimers[i].firesAt <= mNow)
+                {
+                    TimerContext * context = mTimers[i].context;
+                    mTimers[i]             = mTimers[mCount - 1];
+                    mCount--;
+                    context->TimerFired();
+                    fired = true;
+                    break;
+                }
+            }
+        }
+    }
+
+    size_t ActiveCount() const { return mCount; }
+
+private:
+    struct Entry
+    {
+        TimerContext * context = nullptr;
+        System::Clock::Timestamp firesAt;
+    };
+
+    Entry mTimers[kMaxTimers];
+    size_t mCount                 = 0;
+    CHIP_ERROR mNextStartResult   = CHIP_NO_ERROR;
+    System::Clock::Timestamp mNow = System::Clock::Milliseconds64(0);
+};
+
+} // namespace CommissioningProxy
+} // namespace Clusters
+} // namespace app
+} // namespace chip
diff --git a/src/app/clusters/commissioning-proxy-server/tests/CommissioningProxyMockTransport.cpp b/src/app/clusters/commissioning-proxy-server/tests/CommissioningProxyMockTransport.cpp
new file mode 100644
index 0000000..c58732a
--- /dev/null
+++ b/src/app/clusters/commissioning-proxy-server/tests/CommissioningProxyMockTransport.cpp
@@ -0,0 +1,125 @@
+/*
+ *
+ *    Copyright (c) 2026 Project CHIP Authors
+ *    All rights reserved.
+ *
+ *    Licensed under the Apache License, Version 2.0 (the "License");
+ *    you may not use this file except in compliance with the License.
+ *    You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *    Unless required by applicable law or agreed to in writing, software
+ *    distributed under the License is distributed on an "AS IS" BASIS,
+ *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *    See the License for the specific language governing permissions and
+ *    limitations under the License.
+ */
+#include <app/clusters/commissioning-proxy-server/tests/CommissioningProxyMockTransport.h>
+
+#include <clusters/CommissioningProxy/Commands.h>
+
+#include <array>
+
+namespace chip {
+namespace app {
+namespace Clusters {
+namespace CommissioningProxy {
+
+using Status     = Protocols::InteractionModel::Status;
+using ScanResult = Structs::ScanResultStruct::Type;
+
+Status CommissioningProxyMockTransport::Connect(app::CommandHandler * commandObj, const DataModel::InvokeRequest & request,
+                                                uint16_t discriminator, System::Clock::Seconds16 timeout)
+{
+    // A forced non-success result models a transport that could not connect: no
+    // session is registered and no response is sent (the cluster surfaces the status).
+    if (mConnectStatus != Status::Success)
+    {
+        return mConnectStatus;
+    }
+
+    uint16_t sessionId = mHost->Sessions().AllocSessionId();
+    mHost->Sessions().RegisterSession(sessionId, mType, request.subjectDescriptor.fabricIndex);
+    mLastSessionId = sessionId;
+
+    Commands::ProxyConnectResponse::Type response;
+    response.sessionID = sessionId;
+    commandObj->AddResponse(request.path, response);
+
+    // Mirror the real transports' success path: the link is up, so the cluster is
+    // connected. SetCPState only ever returns CHIP_NO_ERROR here.
+    LogErrorOnFailure(mHost->SetCPState(CommissioningProxyCluster::kState_CPConnected));
+    return Status::Success;
+}
+
+CHIP_ERROR CommissioningProxyMockTransport::SendMessage(uint16_t sessionId, System::PacketBufferHandle && buf)
+{
+    if (mSendMessageError != CHIP_NO_ERROR)
+    {
+        return mSendMessageError;
+    }
+
+    // Simulate an immediate commissionee reply (null message) so a pending
+    // ProxyMessageRequest completes synchronously. With auto-respond off the request
+    // stays pending, as it would against a commissionee that never replies — only the
+    // session's response timer can resolve it.
+    if (mAutoRespond)
+    {
+        mHost->Sessions().DispatchMessageResponse(sessionId, nullptr, 0);
+    }
+
+    return CHIP_NO_ERROR;
+}
+
+Status CommissioningProxyMockTransport::Scan(System::Clock::Seconds16 scanMaxTime)
+{
+    if (mScanStatus != Status::Success)
+    {
+        return mScanStatus;
+    }
+
+    // When mAutoContribute is false the scan is left in-flight, so a second
+    // ProxyScanRequest sees the aggregator busy and ContributeScanResults() stands in for
+    // the driver's own scan-completion callback.
+    if (mAutoContribute)
+    {
+        ContributeScanResults();
+    }
+    return Status::Success;
+}
+
+void CommissioningProxyMockTransport::FailPendingMessage(uint16_t sessionId, Status status)
+{
+    mHost->Sessions().DispatchMessageFailure(sessionId, status);
+}
+
+void CommissioningProxyMockTransport::ContributeScanResults()
+{
+    static const uint8_t kAddr1[] = { 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0x01 };
+    static const uint8_t kAddr2[] = { 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0x02 };
+    static const uint8_t kExt1[]  = { 0x10, 0x20, 0x30 };
+
+    std::array<ScanResult, 2> results{};
+    results[0].address       = DataModel::Nullable<ByteSpan>(ByteSpan(kAddr1, sizeof(kAddr1)));
+    results[0].discriminator = 3840;
+    results[0].vendorID      = static_cast<VendorId>(0x1234);
+    results[0].productID     = 0x0001;
+    results[0].extendedData  = DataModel::Nullable<ByteSpan>(ByteSpan(kExt1, sizeof(kExt1)));
+    results[0].transport.Set(mType);
+
+    results[1].address       = DataModel::Nullable<ByteSpan>(ByteSpan(kAddr2, sizeof(kAddr2)));
+    results[1].discriminator = 1234;
+    results[1].vendorID      = static_cast<VendorId>(0x1234);
+    results[1].productID     = 0x0002;
+    results[1].extendedData  = DataModel::Nullable<ByteSpan>();
+    results[1].transport.Set(mType);
+
+    // The aggregator deep-copies, so the local backing storage need not outlive this.
+    mHost->ScanAggregator().Contribute(Span<const ScanResult>(results.data(), results.size()));
+}
+
+} // namespace CommissioningProxy
+} // namespace Clusters
+} // namespace app
+} // namespace chip
diff --git a/src/app/clusters/commissioning-proxy-server/tests/CommissioningProxyMockTransport.h b/src/app/clusters/commissioning-proxy-server/tests/CommissioningProxyMockTransport.h
new file mode 100644
index 0000000..7f56d3b
--- /dev/null
+++ b/src/app/clusters/commissioning-proxy-server/tests/CommissioningProxyMockTransport.h
@@ -0,0 +1,157 @@
+/*
+ *
+ *    Copyright (c) 2026 Project CHIP Authors
+ *    All rights reserved.
+ *
+ *    Licensed under the Apache License, Version 2.0 (the "License");
+ *    you may not use this file except in compliance with the License.
+ *    You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *    Unless required by applicable law or agreed to in writing, software
+ *    distributed under the License is distributed on an "AS IS" BASIS,
+ *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *    See the License for the specific language governing permissions and
+ *    limitations under the License.
+ */
+
+#pragma once
+
+#include <app/clusters/commissioning-proxy-server/CommissioningProxyCluster.h>
+#include <app/clusters/commissioning-proxy-server/CommissioningProxyTransport.h>
+
+namespace chip {
+namespace app {
+namespace Clusters {
+namespace CommissioningProxy {
+
+/**
+ * @brief Test double for a platform transport driver.
+ *
+ * Simulates a transport that completes synchronously so unit tests can drive the
+ * cluster's dispatch/orchestration without a real BLE/PAF stack or event loop.
+ * Each behaviour has a knob so a test can force a specific status or error path.
+ */
+class CommissioningProxyMockTransport : public CommissioningProxyTransport
+{
+public:
+    explicit CommissioningProxyMockTransport(CapabilitiesBitmap type = CapabilitiesBitmap::kBle) : mType(type) {}
+
+    CapabilitiesBitmap GetTransportType() const override { return mType; }
+    void SetHost(CommissioningProxyCluster * host) override { mHost = host; }
+
+    Protocols::InteractionModel::Status Connect(app::CommandHandler * commandObj, const DataModel::InvokeRequest & request,
+                                                uint16_t discriminator, System::Clock::Seconds16 timeout) override;
+    Protocols::InteractionModel::Status CancelPendingConnect(FabricIndex fabricIndex) override
+    {
+        mCancelCalled     = true;
+        mLastCancelFabric = fabricIndex;
+        // Model a real driver: InvalidInState if no connect is pending; Success if
+        // the pending connect is owned by this fabric; NotFound otherwise.
+        if (mPendingConnectFabric == kUndefinedFabricIndex)
+        {
+            return Protocols::InteractionModel::Status::InvalidInState;
+        }
+        if (fabricIndex != mPendingConnectFabric)
+        {
+            return Protocols::InteractionModel::Status::NotFound;
+        }
+
+        // The connect is no longer pending once it has been cancelled.
+        mPendingConnectFabric = kUndefinedFabricIndex;
+        return Protocols::InteractionModel::Status::Success;
+    }
+    Protocols::InteractionModel::Status Disconnect(uint16_t sessionId) override { return mDisconnectStatus; }
+    CHIP_ERROR SendMessage(uint16_t sessionId, System::PacketBufferHandle && buf) override;
+    Protocols::InteractionModel::Status Scan(System::Clock::Seconds16 scanMaxTime) override;
+    Protocols::InteractionModel::Status BgScanStart(System::Clock::Seconds16 timeout, BitMask<WiFiBandBitmap> wiFiBands,
+                                                    FabricIndex fabricIndex, NodeId nodeId) override
+    {
+        if (mBgScanStartStatus == Protocols::InteractionModel::Status::Success)
+        {
+            mBgScanRunning = true;
+        }
+        return mBgScanStartStatus;
+    }
+    Protocols::InteractionModel::Status BgScanStop(BitMask<CapabilitiesBitmap> transport, BitMask<WiFiBandBitmap> wiFiBands,
+                                                   FabricIndex fabricIndex, NodeId nodeId) override
+    {
+        mBgScanStopCount++;
+        mBgScanRunning = false;
+        return mBgScanStopStatus;
+    }
+    void OnAllSessionsClosed() override { mOnAllSessionsClosedCount++; }
+    void OnFabricRemoved(FabricIndex fabricIndex) override
+    {
+        mFabricRemovedCount++;
+        mLastRemovedFabric = fabricIndex;
+    }
+    bool IsConnectPending() const override { return mConnectPending; }
+    void Shutdown() override {}
+
+    // --- Test Control  ---------------------------------------------------------
+    void SetConnectStatus(Protocols::InteractionModel::Status s) { mConnectStatus = s; }
+    void SetConnectPending(bool p) { mConnectPending = p; }
+    // Simulate a pending ProxyConnectRequest owned by @p f (kUndefinedFabricIndex =
+    // none), for the null-SessionID CancelPendingConnect paths.
+    void SetPendingConnectFabric(FabricIndex f) { mPendingConnectFabric = f; }
+    void SetDisconnectStatus(Protocols::InteractionModel::Status s) { mDisconnectStatus = s; }
+    void SetScanStatus(Protocols::InteractionModel::Status s) { mScanStatus = s; }
+    void SetBgScanStartStatus(Protocols::InteractionModel::Status s) { mBgScanStartStatus = s; }
+    /// True once BgScanStart() succeeded and no BgScanStop() has followed.
+    bool BgScanRunning() const { return mBgScanRunning; }
+    unsigned BgScanStopCount() const { return mBgScanStopCount; }
+    void SetBgScanStopStatus(Protocols::InteractionModel::Status s) { mBgScanStopStatus = s; }
+    void SetSendMessageError(CHIP_ERROR e) { mSendMessageError = e; }
+    // When true (default), a successful SendMessage synchronously delivers a null
+    // ProxyMessageResponse back through the session manager. Set false to leave the
+    // request pending, as a commissionee that never replies would (e.g. to exercise the
+    // BUSY path on a second request, or the session's response timeout).
+    void SetAutoRespond(bool a) { mAutoRespond = a; }
+    // When true (default), a successful Scan synchronously contributes results to the
+    // aggregator. Set false to leave the foreground scan in-flight (e.g. to exercise
+    // the concurrent-scan BUSY path on a second ProxyScanRequest).
+    void SetAutoContribute(bool a) { mAutoContribute = a; }
+    /// Deliver this transport's two scan results to the aggregator now, standing in for
+    /// the driver's own scan-completion callback. Pairs with SetAutoContribute(false).
+    void ContributeScanResults();
+    /// Report an asynchronous failure for @p sessionId's pending ProxyMessageRequest, as a
+    /// driver does when its transport connection drops mid-exchange.
+    void FailPendingMessage(uint16_t sessionId, Protocols::InteractionModel::Status status);
+
+    uint16_t LastSessionId() const { return mLastSessionId; }
+    uint8_t OnAllSessionsClosedCount() const { return mOnAllSessionsClosedCount; }
+    bool CancelCalled() const { return mCancelCalled; }
+    unsigned FabricRemovedCount() const { return mFabricRemovedCount; }
+    FabricIndex LastRemovedFabric() const { return mLastRemovedFabric; }
+    FabricIndex LastCancelFabric() const { return mLastCancelFabric; }
+
+private:
+    CapabilitiesBitmap mType;
+    CommissioningProxyCluster * mHost = nullptr;
+
+    Protocols::InteractionModel::Status mConnectStatus     = Protocols::InteractionModel::Status::Success;
+    Protocols::InteractionModel::Status mDisconnectStatus  = Protocols::InteractionModel::Status::Success;
+    Protocols::InteractionModel::Status mScanStatus        = Protocols::InteractionModel::Status::Success;
+    bool mBgScanRunning                                    = false;
+    unsigned mBgScanStopCount                              = 0;
+    Protocols::InteractionModel::Status mBgScanStartStatus = Protocols::InteractionModel::Status::Success;
+    Protocols::InteractionModel::Status mBgScanStopStatus  = Protocols::InteractionModel::Status::Success;
+    CHIP_ERROR mSendMessageError                           = CHIP_NO_ERROR;
+    bool mAutoRespond                                      = true;
+    bool mAutoContribute                                   = true;
+    bool mConnectPending                                   = false;
+    uint16_t mLastSessionId                                = 0;
+    uint8_t mOnAllSessionsClosedCount                      = 0;
+    bool mCancelCalled                                     = false;
+    FabricIndex mLastCancelFabric                          = kUndefinedFabricIndex;
+    FabricIndex mPendingConnectFabric                      = kUndefinedFabricIndex;
+    unsigned mFabricRemovedCount                           = 0;
+    FabricIndex mLastRemovedFabric                         = kUndefinedFabricIndex;
+};
+
+} // namespace CommissioningProxy
+} // namespace Clusters
+} // namespace app
+} // namespace chip
diff --git a/src/app/clusters/commissioning-proxy-server/tests/TestCommissioningProxyBgScanRegistry.cpp b/src/app/clusters/commissioning-proxy-server/tests/TestCommissioningProxyBgScanRegistry.cpp
new file mode 100644
index 0000000..3436a3c
--- /dev/null
+++ b/src/app/clusters/commissioning-proxy-server/tests/TestCommissioningProxyBgScanRegistry.cpp
@@ -0,0 +1,628 @@
+/*
+ *
+ *    Copyright (c) 2026 Project CHIP Authors
+ *    All rights reserved.
+ *
+ *    Licensed under the Apache License, Version 2.0 (the "License");
+ *    you may not use this file except in compliance with the License.
+ *    You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *    Unless required by applicable law or agreed to in writing, software
+ *    distributed under the License is distributed on an "AS IS" BASIS,
+ *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *    See the License for the specific language governing permissions and
+ *    limitations under the License.
+ */
+
+#include "CommissioningProxyMockTimer.h"
+#include <app/clusters/commissioning-proxy-server/CommissioningProxyBgScanRegistry.h>
+#include <pw_unit_test/framework.h>
+
+using namespace chip;
+using namespace chip::app::Clusters::CommissioningProxy;
+using chip::Protocols::InteractionModel::Status;
+
+namespace {
+constexpr FabricIndex kFabric1 = 1;
+constexpr FabricIndex kFabric2 = 2;
+constexpr NodeId kNode1        = 0x1111;
+constexpr NodeId kNode2        = 0x2222;
+constexpr NodeId kNode3        = 0x3333;
+constexpr NodeId kNode4        = 0x4444;
+constexpr NodeId kNode5        = 0x5555;
+
+const BitMask<CapabilitiesBitmap> kBle{ CapabilitiesBitmap::kBle };
+const BitMask<CapabilitiesBitmap> kPaf{ CapabilitiesBitmap::kWiFiPAF };
+const BitMask<CapabilitiesBitmap> kNoTransport{};
+const BitMask<WiFiBandBitmap> k2g4{ WiFiBandBitmap::k2g4 };
+const BitMask<WiFiBandBitmap> k5g{ WiFiBandBitmap::k5g };
+const BitMask<WiFiBandBitmap> kBoth{ WiFiBandBitmap::k2g4, WiFiBandBitmap::k5g };
+const BitMask<WiFiBandBitmap> kNoBands{};
+
+// Records the hardware calls the registry makes and lets a test program the
+// StartHardwareScan() result (to model the BUSY-defer and hard-failure paths).
+class MockHardwareControl : public CommissioningProxyBgScanRegistry::HardwareControl
+{
+public:
+    CHIP_ERROR StartHardwareScan() override
+    {
+        startCount++;
+        return startResult;
+    }
+    void StopHardwareScan() override { stopCount++; }
+    void ClearCachedResults(BitMask<WiFiBandBitmap> bands) override
+    {
+        clearCount++;
+        lastClearedBands = bands;
+    }
+
+    int startCount = 0;
+    int stopCount  = 0;
+    int clearCount = 0;
+    BitMask<WiFiBandBitmap> lastClearedBands{};
+    CHIP_ERROR startResult = CHIP_NO_ERROR;
+};
+
+// Most tests pass timeoutSecs == 0 so no lifetime timer is armed and the registry
+// logic is exercised synchronously. The lifetime tests at the end instead arm a timer
+// and advance CommissioningProxyMockTimer's virtual clock.
+constexpr System::Clock::Seconds16 kNoTimeout{ 0 };
+
+// Every test drives one registry over one mock radio and one virtual clock. Declaration
+// order matters: reg is destroyed first, so its Shutdown() still has both to talk to.
+struct TestCommissioningProxyBgScanRegistry : public ::testing::Test
+{
+    MockHardwareControl hw;
+    CommissioningProxyMockTimer timers;
+    CommissioningProxyBgScanRegistry reg{ hw, timers };
+};
+
+} // namespace
+
+TEST_F(TestCommissioningProxyBgScanRegistry, FirstFabricStartsHardware)
+{
+    EXPECT_EQ(reg.Start(kFabric1, kNode1, kPaf, k2g4, kNoTimeout), Status::Success);
+    EXPECT_EQ(hw.startCount, 1);
+    EXPECT_FALSE(reg.IsEmpty());
+    EXPECT_FALSE(reg.IsPaused());
+}
+
+TEST_F(TestCommissioningProxyBgScanRegistry, SecondFabricDoesNotRestartHardware)
+{
+    EXPECT_EQ(reg.Start(kFabric1, kNode1, kPaf, k2g4, kNoTimeout), Status::Success);
+    EXPECT_EQ(reg.Start(kFabric2, kNode2, kPaf, k2g4, kNoTimeout), Status::Success);
+    EXPECT_EQ(hw.startCount, 1); // already running; not restarted
+}
+
+TEST_F(TestCommissioningProxyBgScanRegistry, StartBusyStaysPausedAndKeepsFabric)
+{
+    hw.startResult = CHIP_ERROR_BUSY; // radio held (BLE scanner / PAF connect slot)
+
+    EXPECT_EQ(reg.Start(kFabric1, kNode1, kPaf, k2g4, kNoTimeout), Status::Success);
+    EXPECT_EQ(hw.startCount, 1);
+    EXPECT_TRUE(reg.IsPaused());
+    EXPECT_FALSE(reg.IsEmpty()); // fabric kept, deferred
+
+    // Radio frees up: resume restarts the hardware scan.
+    hw.startResult = CHIP_NO_ERROR;
+    reg.ResumeIfNeeded();
+    EXPECT_EQ(hw.startCount, 2);
+    EXPECT_FALSE(reg.IsPaused());
+}
+
+TEST_F(TestCommissioningProxyBgScanRegistry, StartHardFailureRejectsAndDropsFabric)
+{
+    hw.startResult = CHIP_ERROR_INTERNAL;
+
+    EXPECT_EQ(reg.Start(kFabric1, kNode1, kPaf, k2g4, kNoTimeout), Status::Failure);
+    EXPECT_TRUE(reg.IsEmpty());
+    // Nothing was started, so the radio is not stopped and the cache is not cleared.
+    EXPECT_EQ(hw.stopCount, 0);
+    EXPECT_EQ(hw.clearCount, 0);
+}
+
+// A failed Start SHALL be a no-op: a hard hardware failure while refreshing an
+// existing fabric must not destroy the registration that was already working.
+TEST_F(TestCommissioningProxyBgScanRegistry, StartHardFailureOnRefreshKeepsExistingRegistration)
+{
+    EXPECT_EQ(reg.Start(kFabric1, kNode1, kPaf, k2g4, kNoTimeout), Status::Success);
+
+    // The radio is taken for a connect: the registry pauses (one stop so far).
+    reg.Pause();
+    EXPECT_TRUE(reg.IsPaused());
+    EXPECT_EQ(hw.stopCount, 1);
+
+    // Refreshing that same fabric now fails hard.
+    hw.startResult = CHIP_ERROR_INTERNAL;
+    EXPECT_EQ(reg.Start(kFabric1, kNode1, kPaf, k5g, kNoTimeout), Status::Failure);
+
+    // The previous registration survives, still paused and awaiting a resume; nothing
+    // extra was stopped and no cached results were thrown away.
+    EXPECT_FALSE(reg.IsEmpty());
+    EXPECT_TRUE(reg.IsPaused());
+    EXPECT_EQ(hw.stopCount, 1);
+    EXPECT_EQ(hw.clearCount, 0);
+
+    // The old bands are intact: a stop for 2G4 still overlaps.
+    hw.startResult = CHIP_NO_ERROR;
+    EXPECT_EQ(reg.Stop(kFabric1, kNode1, kNoTransport, k2g4), Status::Success);
+    EXPECT_TRUE(reg.IsEmpty());
+}
+
+TEST_F(TestCommissioningProxyBgScanRegistry, StopLastFabricStopsHardwareAndClears)
+{
+    EXPECT_EQ(reg.Start(kFabric1, kNode1, kBle, kNoBands, kNoTimeout), Status::Success);
+    EXPECT_EQ(reg.Stop(kFabric1, kNode1, kBle, kNoBands), Status::Success);
+    EXPECT_TRUE(reg.IsEmpty());
+    EXPECT_EQ(hw.stopCount, 1);
+    EXPECT_EQ(hw.clearCount, 1);
+}
+
+TEST_F(TestCommissioningProxyBgScanRegistry, StopUnknownFabricNotFound)
+{
+    EXPECT_EQ(reg.Stop(kFabric1, kNode1, kBle, kNoBands), Status::NotFound);
+}
+
+TEST_F(TestCommissioningProxyBgScanRegistry, StopNoOverlapSucceedsAndKeepsFabric)
+{
+    EXPECT_EQ(reg.Start(kFabric1, kNode1, kPaf, k2g4, kNoTimeout), Status::Success);
+    // Stop targets a different transport (BLE); nothing overlaps this PAF fabric.
+    EXPECT_EQ(reg.Stop(kFabric1, kNode1, kBle, kNoBands), Status::Success);
+    EXPECT_FALSE(reg.IsEmpty()); // fabric untouched
+    EXPECT_EQ(hw.stopCount, 0);
+}
+
+// Also covers the whole-transport clear: once the last band goes the transport itself
+// stops, so everything cached on it goes (bands == 0), not just the band named.
+TEST_F(TestCommissioningProxyBgScanRegistry, StopBandSubsetKeepsFabricThenRemoves)
+{
+    EXPECT_EQ(reg.Start(kFabric1, kNode1, kPaf, kBoth, kNoTimeout), Status::Success);
+
+    // transport==0 means "stop only the given bands": drop 2G4, keep 5G. Nobody scans
+    // 2G4 any more, so its cached results go while the radio keeps running for 5G.
+    EXPECT_EQ(reg.Stop(kFabric1, kNode1, kNoTransport, k2g4), Status::Success);
+    EXPECT_FALSE(reg.IsEmpty());
+    EXPECT_EQ(hw.stopCount, 0);
+    EXPECT_EQ(hw.clearCount, 1);
+    EXPECT_EQ(hw.lastClearedBands.Raw(), k2g4.Raw());
+
+    // Stopping the remaining band empties the fabric and tears the whole transport down.
+    EXPECT_EQ(reg.Stop(kFabric1, kNode1, kNoTransport, k5g), Status::Success);
+    EXPECT_TRUE(reg.IsEmpty());
+    EXPECT_EQ(hw.stopCount, 1);
+    EXPECT_EQ(hw.clearCount, 2);
+    EXPECT_EQ(hw.lastClearedBands.Raw(), 0u);
+}
+
+TEST_F(TestCommissioningProxyBgScanRegistry, StopWithOtherFabricCoveringKeepsHardware)
+{
+    EXPECT_EQ(reg.Start(kFabric1, kNode1, kPaf, k2g4, kNoTimeout), Status::Success);
+    EXPECT_EQ(reg.Start(kFabric2, kNode2, kPaf, k2g4, kNoTimeout), Status::Success);
+
+    // Removing fabric 1 leaves fabric 2 still scanning PAF/2G4 — hardware stays up.
+    EXPECT_EQ(reg.Stop(kFabric1, kNode1, kPaf, k2g4), Status::Success);
+    EXPECT_FALSE(reg.IsEmpty());
+    EXPECT_EQ(hw.stopCount, 0);
+    EXPECT_EQ(hw.clearCount, 0);
+}
+
+TEST_F(TestCommissioningProxyBgScanRegistry, PauseStopsAndResumeRestarts)
+{
+    EXPECT_EQ(reg.Start(kFabric1, kNode1, kBle, kNoBands, kNoTimeout), Status::Success);
+    EXPECT_EQ(hw.startCount, 1);
+
+    reg.Pause();
+    EXPECT_TRUE(reg.IsPaused());
+    EXPECT_EQ(hw.stopCount, 1);
+
+    reg.ResumeIfNeeded();
+    EXPECT_FALSE(reg.IsPaused());
+    EXPECT_EQ(hw.startCount, 2);
+}
+
+TEST_F(TestCommissioningProxyBgScanRegistry, PauseWhenEmptyIsNoop)
+{
+    reg.Pause();
+    EXPECT_FALSE(reg.IsPaused());
+    EXPECT_EQ(hw.stopCount, 0);
+}
+
+TEST_F(TestCommissioningProxyBgScanRegistry, ResumeWhenNotPausedIsNoop)
+{
+    EXPECT_EQ(reg.Start(kFabric1, kNode1, kBle, kNoBands, kNoTimeout), Status::Success);
+    reg.ResumeIfNeeded();
+    EXPECT_EQ(hw.startCount, 1); // was already running
+}
+
+TEST_F(TestCommissioningProxyBgScanRegistry, ResumeStillBusyStaysPaused)
+{
+    EXPECT_EQ(reg.Start(kFabric1, kNode1, kPaf, k2g4, kNoTimeout), Status::Success);
+    reg.Pause();
+    EXPECT_TRUE(reg.IsPaused());
+
+    hw.startResult = CHIP_ERROR_BUSY; // radio still held on the resume attempt
+    reg.ResumeIfNeeded();
+    EXPECT_TRUE(reg.IsPaused()); // stays paused, will retry later
+}
+
+TEST_F(TestCommissioningProxyBgScanRegistry, ShutdownStopsHardware)
+{
+    // Its own registry, not the fixture's: this needs to observe the destructor, so the
+    // lifetime has to end inside the test body. The fixture's own reg holds no fabric, so
+    // its later teardown cannot touch stopCount.
+    {
+        CommissioningProxyBgScanRegistry scoped(hw, timers);
+        EXPECT_EQ(scoped.Start(kFabric1, kNode1, kBle, kNoBands, kNoTimeout), Status::Success);
+        scoped.Shutdown();
+        EXPECT_TRUE(scoped.IsEmpty());
+        EXPECT_EQ(hw.stopCount, 1);
+    }
+    // Destructor runs Shutdown() again on the now-empty registry: no extra stop.
+    EXPECT_EQ(hw.stopCount, 1);
+}
+
+TEST_F(TestCommissioningProxyBgScanRegistry, RefreshExistingFabricUpdatesBands)
+{
+    EXPECT_EQ(reg.Start(kFabric1, kNode1, kPaf, k2g4, kNoTimeout), Status::Success);
+    // Re-register the same fabric with a different band; still one fabric, no restart.
+    EXPECT_EQ(reg.Start(kFabric1, kNode1, kPaf, k5g, kNoTimeout), Status::Success);
+    EXPECT_EQ(hw.startCount, 1);
+
+    // A stop for the OLD band (2G4) no longer overlaps — proves the bands were updated.
+    EXPECT_EQ(reg.Stop(kFabric1, kNode1, kNoTransport, k2g4), Status::Success);
+    EXPECT_FALSE(reg.IsEmpty());
+
+    // Stopping the current band (5G) removes it.
+    EXPECT_EQ(reg.Stop(kFabric1, kNode1, kNoTransport, k5g), Status::Success);
+    EXPECT_TRUE(reg.IsEmpty());
+}
+
+// Spec § ProxyBackGroundScanStartRequest Timeout: "The background scan is
+// automatically stopped when this duration elapses."
+TEST_F(TestCommissioningProxyBgScanRegistry, LifetimeExpiryRemovesFabricAndStopsHardware)
+{
+    EXPECT_EQ(reg.Start(kFabric1, kNode1, kPaf, k2g4, System::Clock::Seconds16(30)), Status::Success);
+    EXPECT_EQ(hw.startCount, 1);
+
+    // Still inside the lifetime: nothing changes.
+    timers.AdvanceClock(System::Clock::Seconds16(29));
+    EXPECT_FALSE(reg.IsEmpty());
+    EXPECT_EQ(hw.stopCount, 0);
+
+    // Spec: "any cached results for that fabric's transports are cleared".
+    timers.AdvanceClock(System::Clock::Seconds16(1));
+    EXPECT_TRUE(reg.IsEmpty());
+    EXPECT_EQ(hw.stopCount, 1);
+    EXPECT_EQ(hw.clearCount, 1);
+}
+
+// Without a lifetime timer the hardware scan would run unbounded, so a StartTimer
+// failure SHALL reject the request and tear down the scan it had already started.
+TEST_F(TestCommissioningProxyBgScanRegistry, LifetimeTimerArmFailureRejectsAndCleansUp)
+{
+    timers.FailNextStart();
+    EXPECT_EQ(reg.Start(kFabric1, kNode1, kPaf, k2g4, System::Clock::Seconds16(30)), Status::Failure);
+
+    EXPECT_TRUE(reg.IsEmpty());
+    EXPECT_FALSE(reg.IsPaused());
+    EXPECT_EQ(hw.startCount, 1); // hardware was started before the timer failed
+    EXPECT_EQ(hw.stopCount, 1);  // ... so it must be stopped again
+    EXPECT_EQ(hw.clearCount, 1);
+}
+
+// Refreshing a node's request cancels the fabric's lifetime timer so it can be re-armed
+// at the new deadline. If the re-arm fails the old timer is already gone, so keeping the
+// fabric registered would scan unbounded — the fabric SHALL be released instead.
+TEST_F(TestCommissioningProxyBgScanRegistry, RefreshTimerArmFailureReleasesTheFabric)
+{
+    EXPECT_EQ(reg.Start(kFabric1, kNode1, kPaf, k2g4, System::Clock::Seconds16(30)), Status::Success);
+    EXPECT_EQ(reg.Start(kFabric1, kNode2, kPaf, k5g, System::Clock::Seconds16(60)), Status::Success);
+    EXPECT_EQ(hw.startCount, 1);
+
+    // Node 1 refreshes its own request with a new timeout; re-arming the fabric's
+    // lifetime fails, so the fabric goes — node 2's request included.
+    timers.FailNextStart();
+    EXPECT_EQ(reg.Start(kFabric1, kNode1, kPaf, k2g4, System::Clock::Seconds16(45)), Status::Failure);
+
+    EXPECT_TRUE(reg.IsEmpty());
+    EXPECT_EQ(hw.stopCount, 1); // nothing is left to scan for
+    EXPECT_EQ(hw.clearCount, 1);
+
+    // No timer survived the release: running past every deadline changes nothing.
+    timers.AdvanceClock(System::Clock::Seconds16(120));
+    EXPECT_TRUE(reg.IsEmpty());
+    EXPECT_EQ(hw.stopCount, 1);
+
+    // The freed slot carries no request into the next fabric to claim it.
+    EXPECT_EQ(reg.Start(kFabric2, kNode3, kPaf, k2g4, kNoTimeout), Status::Success);
+    EXPECT_EQ(reg.Stop(kFabric2, kNode2, kPaf, k5g), Status::NotFound);
+}
+
+// Releasing a fabric on a re-arm failure must not disturb another fabric's scan; only the
+// bands the released requests were the last to want stop being cached.
+TEST_F(TestCommissioningProxyBgScanRegistry, RefreshTimerArmFailureKeepsOtherFabricScanning)
+{
+    EXPECT_EQ(reg.Start(kFabric1, kNode1, kPaf, k5g, System::Clock::Seconds16(30)), Status::Success);
+    EXPECT_EQ(reg.Start(kFabric2, kNode2, kPaf, k2g4, kNoTimeout), Status::Success);
+
+    timers.FailNextStart();
+    EXPECT_EQ(reg.Start(kFabric1, kNode1, kPaf, k5g, System::Clock::Seconds16(45)), Status::Failure);
+
+    EXPECT_FALSE(reg.IsEmpty()); // fabric 2 still wants the radio
+    EXPECT_EQ(hw.stopCount, 0);
+    EXPECT_EQ(hw.clearCount, 1);
+    EXPECT_EQ(hw.lastClearedBands.Raw(), k5g.Raw()); // fabric 2 still scans 2G4
+}
+
+// A fabric released because its lifetime could not be re-armed takes its surviving
+// requests' bands with it, so their cached results are dropped too — even when another
+// fabric keeps the radio running.
+TEST_F(TestCommissioningProxyBgScanRegistry, ReArmFailureOnStopClearsTheReleasedFabricsBands)
+{
+    EXPECT_EQ(reg.Start(kFabric1, kNode1, kPaf, k2g4, System::Clock::Seconds16(30)), Status::Success);
+    EXPECT_EQ(reg.Start(kFabric1, kNode2, kPaf, k5g, System::Clock::Seconds16(60)), Status::Success);
+    EXPECT_EQ(reg.Start(kFabric2, kNode3, kPaf, k2g4, kNoTimeout), Status::Success);
+
+    // Node 1 stopping shortens fabric 1 to node 2's deadline; the re-arm fails, so
+    // fabric 1 is released and node 2's 5G results are no longer wanted by anyone.
+    timers.FailNextStart();
+    EXPECT_EQ(reg.Stop(kFabric1, kNode1, kPaf, k2g4), Status::Success);
+
+    EXPECT_FALSE(reg.IsEmpty());
+    EXPECT_EQ(hw.stopCount, 0);
+    EXPECT_EQ(hw.lastClearedBands.Raw(), k5g.Raw());
+}
+
+// Dropping a fabric because its lifetime could not be re-armed must take the fabric's
+// surviving requests with it: the next fabric to occupy the freed slot cannot inherit
+// requests it never made.
+TEST_F(TestCommissioningProxyBgScanRegistry, LifetimeReArmFailureDropsTheFabricsRequests)
+{
+    EXPECT_EQ(reg.Start(kFabric1, kNode1, kPaf, k2g4, System::Clock::Seconds16(30)), Status::Success);
+    EXPECT_EQ(reg.Start(kFabric1, kNode2, kPaf, k5g, System::Clock::Seconds16(60)), Status::Success);
+
+    // Node 1 stopping shortens the fabric to node 2's deadline, so the lifetime is
+    // re-armed — and that failing drops the fabric, node 2's request included.
+    timers.FailNextStart();
+    EXPECT_EQ(reg.Stop(kFabric1, kNode1, kPaf, k2g4), Status::Success);
+    EXPECT_TRUE(reg.IsEmpty());
+    EXPECT_EQ(hw.stopCount, 1);
+
+    // Another fabric takes the freed slot; node 2's request is not part of it.
+    EXPECT_EQ(reg.Start(kFabric2, kNode3, kPaf, k2g4, kNoTimeout), Status::Success);
+    EXPECT_EQ(reg.Stop(kFabric2, kNode2, kPaf, k5g), Status::NotFound);
+    EXPECT_EQ(reg.Stop(kFabric2, kNode3, kPaf, k2g4), Status::Success);
+    EXPECT_TRUE(reg.IsEmpty());
+}
+
+// Spec: the proxy keeps per-fabric records and scans "until the timeout fires or
+// ProxyBackGroundScanStopRequest is received for that fabric". Each fabric's lifetime
+// is therefore independent: expiring one must not disturb another.
+TEST_F(TestCommissioningProxyBgScanRegistry, IndependentLifetimesExpireSeparately)
+{
+    EXPECT_EQ(reg.Start(kFabric1, kNode1, kPaf, k2g4, System::Clock::Seconds16(10)), Status::Success);
+    EXPECT_EQ(reg.Start(kFabric2, kNode2, kPaf, k2g4, System::Clock::Seconds16(60)), Status::Success);
+    EXPECT_EQ(hw.startCount, 1);
+
+    // Fabric 1 expires; fabric 2 still wants the scan, so the radio keeps running.
+    timers.AdvanceClock(System::Clock::Seconds16(10));
+    EXPECT_FALSE(reg.IsEmpty());
+    EXPECT_EQ(hw.stopCount, 0);
+    EXPECT_EQ(hw.clearCount, 0);
+
+    // Fabric 2 expires: last fabric gone, scan torn down.
+    timers.AdvanceClock(System::Clock::Seconds16(50));
+    EXPECT_TRUE(reg.IsEmpty());
+    EXPECT_EQ(hw.stopCount, 1);
+    EXPECT_EQ(hw.clearCount, 1);
+}
+
+// Spec § ProxyBackGroundScanStopRequest: "If the NodeID and FabricID of the client do
+// not match those recorded when background scanning was started for this fabric, the
+// proxy SHALL take no action and the command SHALL be rejected with a status of
+// NOT_FOUND."
+TEST_F(TestCommissioningProxyBgScanRegistry, StopFromNodeWithNoRequestNotFound)
+{
+    EXPECT_EQ(reg.Start(kFabric1, kNode1, kPaf, k2g4, kNoTimeout), Status::Success);
+
+    // Node 2 never started a scan, so it has no request to stop.
+    EXPECT_EQ(reg.Stop(kFabric1, kNode2, kPaf, k2g4), Status::NotFound);
+    EXPECT_FALSE(reg.IsEmpty());
+    EXPECT_EQ(hw.stopCount, 0);
+
+    // The owner still can.
+    EXPECT_EQ(reg.Stop(kFabric1, kNode1, kPaf, k2g4), Status::Success);
+    EXPECT_TRUE(reg.IsEmpty());
+}
+
+// Spec: a Stop is identified by NodeID + FabricID, so each node on a fabric keeps its
+// own request and the fabric scans the union of them. One node's scan is untouched by
+// another's.
+TEST_F(TestCommissioningProxyBgScanRegistry, SecondNodeOnSameFabricAddsItsOwnRequest)
+{
+    EXPECT_EQ(reg.Start(kFabric1, kNode1, kPaf, k2g4, kNoTimeout), Status::Success);
+    EXPECT_EQ(reg.Start(kFabric1, kNode2, kPaf, k5g, kNoTimeout), Status::Success);
+    EXPECT_EQ(hw.startCount, 1); // already running; not restarted for the second
+
+    // Node 1 stopping its own 2G4 leaves node 2's 5G request scanning: the radio stays
+    // on, but 2G4's cached results go since no request covers that band now.
+    EXPECT_EQ(reg.Stop(kFabric1, kNode1, kNoTransport, k2g4), Status::Success);
+    EXPECT_FALSE(reg.IsEmpty());
+    EXPECT_EQ(hw.stopCount, 0);
+    EXPECT_EQ(hw.clearCount, 1);
+    EXPECT_EQ(hw.lastClearedBands.Raw(), k2g4.Raw());
+
+    // Only once the last request goes does the radio stop and the transport clear.
+    EXPECT_EQ(reg.Stop(kFabric1, kNode2, kNoTransport, k5g), Status::Success);
+    EXPECT_TRUE(reg.IsEmpty());
+    EXPECT_EQ(hw.stopCount, 1);
+    EXPECT_EQ(hw.clearCount, 2);
+}
+
+// Spec § ProxyBackGroundScanStopRequest: stop the requested transports and bands
+// "unless another active background scan overlaps with them".
+TEST_F(TestCommissioningProxyBgScanRegistry, StopLeavesBandsAnotherRequestStillCovers)
+{
+    EXPECT_EQ(reg.Start(kFabric1, kNode1, kPaf, kBoth, kNoTimeout), Status::Success);
+    EXPECT_EQ(reg.Start(kFabric1, kNode2, kPaf, k5g, kNoTimeout), Status::Success);
+
+    // Node 1 drops 5G; node 2 still wants it, so scanning continues.
+    EXPECT_EQ(reg.Stop(kFabric1, kNode1, kNoTransport, k5g), Status::Success);
+    EXPECT_FALSE(reg.IsEmpty());
+    EXPECT_EQ(hw.stopCount, 0);
+
+    // Node 1 keeps 2G4: dropping that too removes only node 1's request.
+    EXPECT_EQ(reg.Stop(kFabric1, kNode1, kNoTransport, k2g4), Status::Success);
+    EXPECT_FALSE(reg.IsEmpty());
+    EXPECT_EQ(reg.Stop(kFabric1, kNode2, kNoTransport, k5g), Status::Success);
+    EXPECT_TRUE(reg.IsEmpty());
+}
+
+// The fabric holds one timer, at the latest deadline of its requests, so a shorter
+// request does not end the fabric's scan early.
+TEST_F(TestCommissioningProxyBgScanRegistry, FabricTimerUsesLatestDeadline)
+{
+    EXPECT_EQ(reg.Start(kFabric1, kNode1, kPaf, k2g4, System::Clock::Seconds16(10)), Status::Success);
+    EXPECT_EQ(reg.Start(kFabric1, kNode2, kPaf, k5g, System::Clock::Seconds16(60)), Status::Success);
+
+    timers.AdvanceClock(System::Clock::Seconds16(11));
+    EXPECT_FALSE(reg.IsEmpty()); // node 2 still has 49s to run
+
+    timers.AdvanceClock(System::Clock::Seconds16(49));
+    EXPECT_TRUE(reg.IsEmpty());
+    EXPECT_EQ(hw.stopCount, 1);
+}
+
+// Dropping the request that set the fabric's deadline SHALL pull the timer back to the
+// longest survivor, rather than leave the fabric scanning until the removed deadline.
+TEST_F(TestCommissioningProxyBgScanRegistry, StopShortensFabricTimerToSurvivingRequest)
+{
+    EXPECT_EQ(reg.Start(kFabric1, kNode1, kPaf, k2g4, System::Clock::Seconds16(300)), Status::Success);
+    EXPECT_EQ(reg.Start(kFabric1, kNode2, kPaf, k5g, System::Clock::Seconds16(30)), Status::Success);
+
+    // The 300s request goes; only the 30s one is left.
+    EXPECT_EQ(reg.Stop(kFabric1, kNode1, kNoTransport, k2g4), Status::Success);
+    EXPECT_FALSE(reg.IsEmpty());
+
+    timers.AdvanceClock(System::Clock::Seconds16(31));
+    EXPECT_TRUE(reg.IsEmpty());
+}
+
+// A request with Timeout 0 never expires, so the fabric holds no timer at all while it
+// is registered.
+TEST_F(TestCommissioningProxyBgScanRegistry, NoTimeoutRequestSuppressesFabricTimer)
+{
+    EXPECT_EQ(reg.Start(kFabric1, kNode1, kPaf, k2g4, System::Clock::Seconds16(30)), Status::Success);
+    EXPECT_EQ(reg.Start(kFabric1, kNode2, kPaf, k5g, kNoTimeout), Status::Success);
+    EXPECT_EQ(timers.ActiveCount(), 0u);
+
+    timers.AdvanceClock(System::Clock::Seconds16(60));
+    EXPECT_FALSE(reg.IsEmpty());
+}
+
+// A fabric may only hold so many concurrent requests; the cap does not stop a node that
+// already has one from refreshing it.
+TEST_F(TestCommissioningProxyBgScanRegistry, RequestLimitPerFabricRejectsExtraNodes)
+{
+    static_assert(CHIP_CONFIG_COMMISSIONING_PROXY_MAX_BGSCAN_REQUESTS_PER_FABRIC == 4, "test assumes a cap of 4");
+    EXPECT_EQ(reg.Start(kFabric1, kNode1, kPaf, k2g4, kNoTimeout), Status::Success);
+    EXPECT_EQ(reg.Start(kFabric1, kNode2, kPaf, k2g4, kNoTimeout), Status::Success);
+    EXPECT_EQ(reg.Start(kFabric1, kNode3, kPaf, k2g4, kNoTimeout), Status::Success);
+    EXPECT_EQ(reg.Start(kFabric1, kNode4, kPaf, k2g4, kNoTimeout), Status::Success);
+
+    EXPECT_EQ(reg.Start(kFabric1, kNode5, kPaf, k2g4, kNoTimeout), Status::ResourceExhausted);
+
+    // An existing node refreshing is not a new request.
+    EXPECT_EQ(reg.Start(kFabric1, kNode2, kPaf, k5g, kNoTimeout), Status::Success);
+
+    // The cap is per fabric, so another fabric is unaffected.
+    EXPECT_EQ(reg.Start(kFabric2, kNode5, kPaf, k2g4, kNoTimeout), Status::Success);
+}
+
+// Spec § ProxyBackGroundScanStopRequest: "clear all cached results for the transports
+// and bands on which it has stopped scanning" — so a band another fabric still scans
+// keeps its results.
+TEST_F(TestCommissioningProxyBgScanRegistry, StopClearsOnlyBandsNoFabricStillScans)
+{
+    EXPECT_EQ(reg.Start(kFabric1, kNode1, kPaf, kBoth, kNoTimeout), Status::Success);
+    EXPECT_EQ(reg.Start(kFabric2, kNode2, kPaf, k5g, kNoTimeout), Status::Success);
+
+    // Fabric 1 drops both bands. 5G survives on fabric 2, so only 2G4's results go.
+    EXPECT_EQ(reg.Stop(kFabric1, kNode1, kNoTransport, kBoth), Status::Success);
+    EXPECT_EQ(hw.clearCount, 1);
+    EXPECT_EQ(hw.lastClearedBands.Raw(), k2g4.Raw());
+    EXPECT_EQ(hw.stopCount, 0); // fabric 2 still wants the radio
+}
+
+// A band that another fabric still scans SHALL not have its results cleared at all.
+TEST_F(TestCommissioningProxyBgScanRegistry, StopOfSharedBandClearsNothing)
+{
+    EXPECT_EQ(reg.Start(kFabric1, kNode1, kPaf, k2g4, kNoTimeout), Status::Success);
+    EXPECT_EQ(reg.Start(kFabric2, kNode2, kPaf, k2g4, kNoTimeout), Status::Success);
+
+    EXPECT_EQ(reg.Stop(kFabric1, kNode1, kNoTransport, k2g4), Status::Success);
+    EXPECT_EQ(hw.clearCount, 0); // fabric 2 still scans 2G4
+    EXPECT_FALSE(reg.IsEmpty());
+}
+
+// Spec § ProxyBackGroundScanStartRequest: "When the per-fabric Timeout elapses ... any
+// cached results for that fabric's transports are cleared" — again only for bands no
+// surviving fabric covers.
+TEST_F(TestCommissioningProxyBgScanRegistry, LifetimeExpiryClearsOnlyItsOwnBands)
+{
+    EXPECT_EQ(reg.Start(kFabric1, kNode1, kPaf, k2g4, System::Clock::Seconds16(30)), Status::Success);
+    EXPECT_EQ(reg.Start(kFabric2, kNode2, kPaf, k5g, kNoTimeout), Status::Success);
+
+    timers.AdvanceClock(System::Clock::Seconds16(31));
+
+    EXPECT_FALSE(reg.IsEmpty()); // fabric 2 has no timeout
+    EXPECT_EQ(hw.clearCount, 1);
+    EXPECT_EQ(hw.lastClearedBands.Raw(), k2g4.Raw());
+    EXPECT_EQ(hw.stopCount, 0);
+}
+
+// FabricIndex values are reused after a fabric is removed, so a Timeout-0 request —
+// which has no timer and would otherwise never end — must not outlive its fabric.
+TEST_F(TestCommissioningProxyBgScanRegistry, RemoveFabricDropsItsRequestsAndStopsHardware)
+{
+    EXPECT_EQ(reg.Start(kFabric1, kNode1, kPaf, k2g4, kNoTimeout), Status::Success);
+    EXPECT_EQ(reg.Start(kFabric1, kNode2, kPaf, k5g, kNoTimeout), Status::Success);
+
+    reg.RemoveFabric(kFabric1);
+
+    EXPECT_TRUE(reg.IsEmpty());
+    EXPECT_EQ(hw.stopCount, 1);
+    EXPECT_EQ(hw.clearCount, 1);
+    EXPECT_EQ(hw.lastClearedBands.Raw(), 0u); // whole transport, nothing left scanning
+
+    // The reused index starts clean: a stop from the old node finds nothing.
+    EXPECT_EQ(reg.Stop(kFabric1, kNode1, kNoTransport, k2g4), Status::NotFound);
+}
+
+// Removing one fabric SHALL leave another fabric's scan running, clearing only the
+// bands that nobody covers any more.
+TEST_F(TestCommissioningProxyBgScanRegistry, RemoveFabricLeavesOtherFabricsScanning)
+{
+    EXPECT_EQ(reg.Start(kFabric1, kNode1, kPaf, k2g4, kNoTimeout), Status::Success);
+    EXPECT_EQ(reg.Start(kFabric2, kNode2, kPaf, k5g, kNoTimeout), Status::Success);
+
+    reg.RemoveFabric(kFabric1);
+
+    EXPECT_FALSE(reg.IsEmpty());
+    EXPECT_EQ(hw.stopCount, 0);
+    EXPECT_EQ(hw.clearCount, 1);
+    EXPECT_EQ(hw.lastClearedBands.Raw(), k2g4.Raw());
+}
+
+// Removing a fabric with no requests is a no-op.
+TEST_F(TestCommissioningProxyBgScanRegistry, RemoveUnknownFabricIsNoop)
+{
+    EXPECT_EQ(reg.Start(kFabric1, kNode1, kPaf, k2g4, kNoTimeout), Status::Success);
+    reg.RemoveFabric(kFabric2);
+
+    EXPECT_FALSE(reg.IsEmpty());
+    EXPECT_EQ(hw.stopCount, 0);
+    EXPECT_EQ(hw.clearCount, 0);
+}
diff --git a/src/app/clusters/commissioning-proxy-server/tests/TestCommissioningProxyCluster.cpp b/src/app/clusters/commissioning-proxy-server/tests/TestCommissioningProxyCluster.cpp
new file mode 100644
index 0000000..608cfd8
--- /dev/null
+++ b/src/app/clusters/commissioning-proxy-server/tests/TestCommissioningProxyCluster.cpp
@@ -0,0 +1,2853 @@
+/*
+ *
+ *    Copyright (c) 2026 Project CHIP Authors
+ *    All rights reserved.
+ *
+ *    Licensed under the Apache License, Version 2.0 (the "License");
+ *    you may not use this file except in compliance with the License.
+ *    You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *    Unless required by applicable law or agreed to in writing, software
+ *    distributed under the License is distributed on an "AS IS" BASIS,
+ *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *    See the License for the specific language governing permissions and
+ *    limitations under the License.
+ */
+
+#include "CommissioningProxyMockTimer.h"
+#include "CommissioningProxyMockTransport.h"
+#include <app/clusters/commissioning-proxy-server/CommissioningProxyCluster.h>
+#include <app/clusters/commissioning-proxy-server/tests/CommissioningProxyMockTransport.h>
+#include <platform/CommissionableDataProvider.h> // for kMaxDiscriminatorValue
+#include <pw_unit_test/framework.h>
+
+#include <app/data-model-provider/tests/ReadTesting.h>
+#include <app/server-cluster/testing/ClusterTester.h>
+#include <app/server-cluster/testing/TestServerClusterContext.h>
+#include <app/server-cluster/testing/ValidateGlobalAttributes.h>
+#include <clusters/CommissioningProxy/Attributes.h>
+#include <clusters/CommissioningProxy/Commands.h>
+#include <clusters/CommissioningProxy/Metadata.h>
+#include <system/SystemClock.h>
+
+using namespace chip;
+using namespace chip::app;
+using namespace chip::app::Clusters;
+using namespace chip::app::Clusters::CommissioningProxy;
+using namespace chip::app::Clusters::CommissioningProxy::Attributes;
+using namespace chip::app::Clusters::CommissioningProxy::Commands;
+using namespace chip::Testing;
+using chip::Protocols::InteractionModel::ClusterStatusCode;
+
+namespace {
+constexpr EndpointId kTestEndpointId = 1;
+struct TestCommissioningProxyCluster : public ::testing::Test
+{
+    // No stack init needed: every cluster timer goes through the mockTimer below, so
+    // nothing under test touches the system layer or the event loop.
+    static void SetUpTestSuite() { ASSERT_EQ(chip::Platform::MemoryInit(), CHIP_NO_ERROR); }
+    static void TearDownTestSuite() { chip::Platform::MemoryShutdown(); }
+
+    void SetUp() override {}
+
+    // Drives the session manager's response timeout and the aggregator's scan watchdog:
+    // virtual time, so expiry costs no wall-clock, plus one-shot StartTimer failure
+    // injection for the rollback paths.
+    CommissioningProxyMockTimer mockTimer;
+
+    // Mock transports available to every test. A transport is "supported" (advertised
+    // in the Transport attribute) iff it is registered, so RegisterMocks() makes both
+    // BLE and Wi-Fi PAF available; tests that need only one register it directly.
+    CommissioningProxyMockTransport mockBle{ CapabilitiesBitmap::kBle };
+    CommissioningProxyMockTransport mockPaf{ CapabilitiesBitmap::kWiFiPAF };
+
+    void RegisterMocks(CommissioningProxyCluster & cluster)
+    {
+        cluster.RegisterTransport(mockBle);
+        cluster.RegisterTransport(mockPaf);
+    }
+};
+
+namespace CPAttributes = chip::app::Clusters::CommissioningProxy::Attributes;
+
+// Helper: read the Transport attribute (CapabilitiesBitmap) — the runtime
+// source of truth for which transports the proxy supports.  A transport bit
+// is set here iff the cluster's GetSupportedTransports() returns it for the
+// current build flag / feature flag combination.
+static chip::BitMask<CapabilitiesBitmap> ReadSupportedTransports(ClusterTester & tester)
+{
+    chip::BitMask<CapabilitiesBitmap> supported;
+    EXPECT_EQ(tester.ReadAttribute(CPAttributes::Transport::Id, supported), CHIP_NO_ERROR);
+    return supported;
+}
+
+// Convenience: skip a test when the named transport is absent from the Transport
+// attribute (e.g. PAF-only test running against a BLE-only build of the cluster).
+#define SKIP_IF_TRANSPORT_UNSUPPORTED(tester, transport)                                                                           \
+    do                                                                                                                             \
+    {                                                                                                                              \
+        if (!ReadSupportedTransports(tester).Has(transport))                                                                       \
+        {                                                                                                                          \
+            GTEST_SKIP() << "Transport " #transport " not advertised by this build of CommissioningProxyCluster";                  \
+        }                                                                                                                          \
+    } while (0)
+
+// Convenience: skip a test that needs more than one concurrent session when this build
+// reserves fewer.  CHIP_CONFIG_COMMISSIONING_PROXY_MAX_SESSIONS sizes the session table
+// at compile time and the default is the spec minimum of 1, so the multi-session paths
+// (per-fabric isolation, distinct session ids, staying Connected until the last session
+// closes) are only reachable in a build that raises it.
+#define SKIP_IF_MAX_SESSIONS_BELOW(needed)                                                                                         \
+    do                                                                                                                             \
+    {                                                                                                                              \
+        if (CHIP_CONFIG_COMMISSIONING_PROXY_MAX_SESSIONS < (needed))                                                               \
+        {                                                                                                                          \
+            GTEST_SKIP() << "Build reserves " << CHIP_CONFIG_COMMISSIONING_PROXY_MAX_SESSIONS                                      \
+                         << " proxy session(s), this case needs " << (needed);                                                     \
+        }                                                                                                                          \
+    } while (0)
+
+// =============================================================================
+// Feature Tests
+// =============================================================================
+TEST_F(TestCommissioningProxyCluster, TestFeatures)
+{
+    TestServerClusterContext context;
+
+    // No features - only mandatory attributes
+    {
+        BitMask<Feature> noFeatures;
+        CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(noFeatures), mockTimer);
+        RegisterMocks(cluster);
+        EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+        EXPECT_TRUE(IsAttributesListEqualTo(cluster,
+                                            {
+                                                CPAttributes::Transport::kMetadataEntry,
+                                                CPAttributes::ScanMaxTime::kMetadataEntry,
+                                                CPAttributes::MaxSessions::kMetadataEntry,
+                                            }));
+
+        ReadOnlyBufferBuilder<DataModel::AcceptedCommandEntry> commandsBuilder;
+        EXPECT_EQ(cluster.AcceptedCommands(ConcreteClusterPath(kTestEndpointId, CommissioningProxy::Id), commandsBuilder),
+                  CHIP_NO_ERROR);
+
+        ReadOnlyBufferBuilder<DataModel::AcceptedCommandEntry> expectedCommandsBuilder;
+        EXPECT_EQ(expectedCommandsBuilder.AppendElements({
+                      ProxyConnectRequest::kMetadataEntry,
+                      ProxyDisconnectRequest::kMetadataEntry,
+                      ProxyScanRequest::kMetadataEntry,
+                      ProxyMessageRequest::kMetadataEntry,
+                  }),
+                  CHIP_NO_ERROR);
+        EXPECT_TRUE(EqualAcceptedCommandSets(commandsBuilder.TakeBuffer(), expectedCommandsBuilder.TakeBuffer()));
+
+        cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+    }
+
+    // Background Scan(BGS) Feature - BGS and mandatory attributes
+    {
+        BitMask<Feature> features(Feature::kBackgroundScan);
+        CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
+        RegisterMocks(cluster);
+        EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+        EXPECT_TRUE(IsAttributesListEqualTo(cluster,
+                                            {
+                                                CPAttributes::Transport::kMetadataEntry,
+                                                CPAttributes::ScanMaxTime::kMetadataEntry,
+                                                CPAttributes::MaxSessions::kMetadataEntry,
+                                                CPAttributes::MaxCachedResults::kMetadataEntry,
+                                                CPAttributes::NumCachedResults::kMetadataEntry,
+                                                CPAttributes::CacheTimeout::kMetadataEntry,
+                                                CPAttributes::CachedResults::kMetadataEntry,
+                                            }));
+
+        ReadOnlyBufferBuilder<DataModel::AcceptedCommandEntry> commandsBuilder;
+        EXPECT_EQ(cluster.AcceptedCommands(ConcreteClusterPath(kTestEndpointId, CommissioningProxy::Id), commandsBuilder),
+                  CHIP_NO_ERROR);
+
+        ReadOnlyBufferBuilder<DataModel::AcceptedCommandEntry> expectedCommandsBuilder;
+        EXPECT_EQ(expectedCommandsBuilder.AppendElements({
+                      ProxyConnectRequest::kMetadataEntry,
+                      ProxyDisconnectRequest::kMetadataEntry,
+                      ProxyScanRequest::kMetadataEntry,
+                      ProxyBackGroundScanStartRequest::kMetadataEntry,
+                      ProxyBackGroundScanStopRequest::kMetadataEntry,
+                      ProxyMessageRequest::kMetadataEntry,
+                  }),
+                  CHIP_NO_ERROR);
+        EXPECT_TRUE(EqualAcceptedCommandSets(commandsBuilder.TakeBuffer(), expectedCommandsBuilder.TakeBuffer()));
+
+        cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+    }
+
+    // WiFi Feature - mandatory attributes plus WiFiBand. WiFiBand is [WI]
+    // (optional under WI); this implementation always exposes it when WI is set.
+    {
+        BitMask<Feature> features(Feature::kWiFiNetworkInterface);
+        CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
+        RegisterMocks(cluster);
+        EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+        EXPECT_TRUE(IsAttributesListEqualTo(cluster,
+                                            {
+                                                CPAttributes::Transport::kMetadataEntry,
+                                                CPAttributes::ScanMaxTime::kMetadataEntry,
+                                                CPAttributes::MaxSessions::kMetadataEntry,
+                                                CPAttributes::WiFiBand::kMetadataEntry,
+                                            }));
+
+        ReadOnlyBufferBuilder<DataModel::AcceptedCommandEntry> commandsBuilder;
+        EXPECT_EQ(cluster.AcceptedCommands(ConcreteClusterPath(kTestEndpointId, CommissioningProxy::Id), commandsBuilder),
+                  CHIP_NO_ERROR);
+
+        ReadOnlyBufferBuilder<DataModel::AcceptedCommandEntry> expectedCommandsBuilder;
+        EXPECT_EQ(expectedCommandsBuilder.AppendElements({
+                      ProxyConnectRequest::kMetadataEntry,
+                      ProxyDisconnectRequest::kMetadataEntry,
+                      ProxyScanRequest::kMetadataEntry,
+                      ProxyMessageRequest::kMetadataEntry,
+                  }),
+                  CHIP_NO_ERROR);
+        EXPECT_TRUE(EqualAcceptedCommandSets(commandsBuilder.TakeBuffer(), expectedCommandsBuilder.TakeBuffer()));
+
+        cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+    }
+
+    // All Features - All attributes
+    {
+        BitMask<Feature> features(Feature::kBackgroundScan, Feature::kWiFiNetworkInterface);
+        CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
+        RegisterMocks(cluster);
+        EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+        EXPECT_TRUE(IsAttributesListEqualTo(cluster,
+                                            {
+                                                CPAttributes::Transport::kMetadataEntry,
+                                                CPAttributes::ScanMaxTime::kMetadataEntry,
+                                                CPAttributes::MaxSessions::kMetadataEntry,
+                                                CPAttributes::MaxCachedResults::kMetadataEntry,
+                                                CPAttributes::NumCachedResults::kMetadataEntry,
+                                                CPAttributes::CacheTimeout::kMetadataEntry,
+                                                CPAttributes::CachedResults::kMetadataEntry,
+                                                CPAttributes::WiFiBand::kMetadataEntry,
+                                            }));
+
+        ReadOnlyBufferBuilder<DataModel::AcceptedCommandEntry> commandsBuilder;
+        EXPECT_EQ(cluster.AcceptedCommands(ConcreteClusterPath(kTestEndpointId, CommissioningProxy::Id), commandsBuilder),
+                  CHIP_NO_ERROR);
+
+        ReadOnlyBufferBuilder<DataModel::AcceptedCommandEntry> expectedCommandsBuilder;
+        EXPECT_EQ(expectedCommandsBuilder.AppendElements({
+                      ProxyConnectRequest::kMetadataEntry,
+                      ProxyDisconnectRequest::kMetadataEntry,
+                      ProxyScanRequest::kMetadataEntry,
+                      ProxyBackGroundScanStartRequest::kMetadataEntry,
+                      ProxyBackGroundScanStopRequest::kMetadataEntry,
+                      ProxyMessageRequest::kMetadataEntry,
+                  }),
+                  CHIP_NO_ERROR);
+        EXPECT_TRUE(EqualAcceptedCommandSets(commandsBuilder.TakeBuffer(), expectedCommandsBuilder.TakeBuffer()));
+
+        cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+    }
+}
+
+// =============================================================================
+// Startup Tests
+// =============================================================================
+TEST_F(TestCommissioningProxyCluster, TestStartupSucceeds)
+{
+    TestServerClusterContext context;
+    BitMask<Feature> noFeatures;
+
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(noFeatures), mockTimer);
+    RegisterMocks(cluster);
+
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// =============================================================================
+// Attribute Tests
+// =============================================================================
+TEST_F(TestCommissioningProxyCluster, TestMandatoryAttributes)
+{
+    TestServerClusterContext context;
+    BitMask<Feature> noFeatures;
+
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(noFeatures), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+
+    // ScanMaxTime has no spec fallback; the cluster uses 10 s as a practical default.
+    // (MaxSessions is read in TestMaxSessionsAttributeReadsFromBuildConfig, which checks it
+    // against the build constant rather than the cluster's own getter.)
+    uint8_t scanMaxTime = 0;
+    ASSERT_EQ(tester.ReadAttribute(CPAttributes::ScanMaxTime::Id, scanMaxTime), CHIP_NO_ERROR);
+    EXPECT_EQ(scanMaxTime, 10);
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// The Transport attribute SHALL advertise exactly the transports that have a
+// registered driver. kBle is not gated by any Feature bit per spec.
+TEST_F(TestCommissioningProxyCluster, TestTransportAttribute_ReflectsRegisteredTransports)
+{
+    TestServerClusterContext context;
+
+    // Register only BLE: Transport advertises kBle, not kWiFiPAF.
+    {
+        CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
+        cluster.RegisterTransport(mockBle);
+        EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+        ClusterTester tester(cluster);
+        auto supported = ReadSupportedTransports(tester);
+        EXPECT_TRUE(supported.Has(CapabilitiesBitmap::kBle));
+        EXPECT_FALSE(supported.Has(CapabilitiesBitmap::kWiFiPAF));
+        cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+    }
+
+    // Register only Wi-Fi PAF: Transport advertises kWiFiPAF, not kBle. WiFiPAF is
+    // independent of the WI feature (WI only gates WiFiBand), so no feature is set.
+    {
+        CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
+        cluster.RegisterTransport(mockPaf);
+        EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+        ClusterTester tester(cluster);
+        auto supported = ReadSupportedTransports(tester);
+        EXPECT_TRUE(supported.Has(CapabilitiesBitmap::kWiFiPAF));
+        EXPECT_FALSE(supported.Has(CapabilitiesBitmap::kBle));
+        cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+    }
+
+    // Register both: Transport advertises both.
+    {
+        CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
+        RegisterMocks(cluster);
+        EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+        ClusterTester tester(cluster);
+        auto supported = ReadSupportedTransports(tester);
+        EXPECT_TRUE(supported.Has(CapabilitiesBitmap::kBle));
+        EXPECT_TRUE(supported.Has(CapabilitiesBitmap::kWiFiPAF));
+        cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+    }
+}
+
+// MaxSessions attribute must reflect the configured GetMaxSessions() value,
+// not a hardcoded constant.
+TEST_F(TestCommissioningProxyCluster, TestMaxSessionsAttributeReadsFromBuildConfig)
+{
+    TestServerClusterContext context;
+    BitMask<Feature> noFeatures;
+
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(noFeatures), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+
+    // MaxSessions is Fixed quality: it is the build-time pool size, not per-instance config.
+    uint8_t maxSessions = 0;
+    ASSERT_EQ(tester.ReadAttribute(CPAttributes::MaxSessions::Id, maxSessions), CHIP_NO_ERROR);
+    EXPECT_EQ(maxSessions, CHIP_CONFIG_COMMISSIONING_PROXY_MAX_SESSIONS);
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// =============================================================================
+// Spec-compliance attribute tests
+// =============================================================================
+// These tests directly assert spec-mandated attribute defaults and access
+// semantics from src/app_clusters/CommissioningProxy.adoc (§ Attributes).
+
+// ClusterRevision SHALL equal the highest value in the Revision History table
+// (currently 1).  Source: zzz_generated/.../Metadata.h kRevision.
+TEST_F(TestCommissioningProxyCluster, TestClusterRevisionEqualsOne)
+{
+    TestServerClusterContext context;
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    uint16_t clusterRevision = 0;
+    ASSERT_EQ(tester.ReadAttribute(chip::app::Clusters::Globals::Attributes::ClusterRevision::Id, clusterRevision), CHIP_NO_ERROR);
+    EXPECT_EQ(clusterRevision, 1u);
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// FeatureMap SHALL reflect the feature bits the cluster was constructed with.
+TEST_F(TestCommissioningProxyCluster, TestFeatureMapReflectsConfig)
+{
+    TestServerClusterContext context;
+    BitMask<Feature> features(Feature::kWiFiNetworkInterface, Feature::kBackgroundScan);
+
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    uint32_t featureMap = 0;
+    ASSERT_EQ(tester.ReadAttribute(chip::app::Clusters::Globals::Attributes::FeatureMap::Id, featureMap), CHIP_NO_ERROR);
+    EXPECT_EQ(featureMap, static_cast<uint32_t>(Feature::kWiFiNetworkInterface) | static_cast<uint32_t>(Feature::kBackgroundScan));
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// Spec § Attributes: Transport, MaxSessions, MaxCachedResults and NumCachedResults are
+// "R V" and WiFiBand is "F R V" — every one of them read-only, so a write SHALL be
+// rejected with UnsupportedWrite. Every feature is enabled here so all five are present.
+TEST_F(TestCommissioningProxyCluster, TestReadOnlyAttributes_WriteRejected)
+{
+    TestServerClusterContext context;
+    BitMask<Feature> features(Feature::kWiFiNetworkInterface, Feature::kBackgroundScan);
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
+    RegisterMocks(cluster);
+    cluster.SetSupportedWiFiBands(chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k2g4));
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    auto expectRejected = [&tester](const char * name, AttributeId id, const auto & value) {
+        auto status = tester.WriteAttribute(id, value);
+        EXPECT_FALSE(status.IsSuccess()) << name;
+        EXPECT_EQ(status.GetStatusCode().GetStatus(), Protocols::InteractionModel::Status::UnsupportedWrite) << name;
+    };
+
+    expectRejected("Transport", CPAttributes::Transport::Id, chip::BitMask<CapabilitiesBitmap>(CapabilitiesBitmap::kBle));
+    expectRejected("MaxSessions", CPAttributes::MaxSessions::Id, static_cast<uint8_t>(5));
+    expectRejected("MaxCachedResults", CPAttributes::MaxCachedResults::Id, static_cast<uint8_t>(5));
+    expectRejected("NumCachedResults", CPAttributes::NumCachedResults::Id, static_cast<uint8_t>(3));
+    expectRejected("WiFiBand", CPAttributes::WiFiBand::Id, chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k5g));
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// Spec § Attributes: ScanMaxTime and CacheTimeout both carry constraint "min 1", so
+// writing 0 to either SHALL be rejected with ConstraintError.
+TEST_F(TestCommissioningProxyCluster, TestWritableAttributes_WriteZeroConstraintError)
+{
+    TestServerClusterContext context;
+    BitMask<Feature> bgs(Feature::kBackgroundScan); // CacheTimeout is BGS-only
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(bgs), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+
+    EXPECT_EQ(tester.WriteAttribute(CPAttributes::ScanMaxTime::Id, static_cast<uint8_t>(0)).GetStatusCode().GetStatus(),
+              Protocols::InteractionModel::Status::ConstraintError);
+    EXPECT_EQ(tester.WriteAttribute(CPAttributes::CacheTimeout::Id, static_cast<uint16_t>(0)).GetStatusCode().GetStatus(),
+              Protocols::InteractionModel::Status::ConstraintError);
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// Spec § Attributes: ScanMaxTime access = "RW VO". Storage and change reporting are owned
+// by the cluster: a write that changes the value SHALL emit a change report and be
+// visible to the next read, and a write of the unchanged value SHALL NOT report.
+TEST_F(TestCommissioningProxyCluster, TestScanMaxTimeAttribute_WritableAndChangeReporting)
+{
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
+    RegisterMocks(cluster);
+
+    // Start with the tester's context so change notifications land in the dirty
+    // list the tester observes.
+    ClusterTester tester(cluster);
+    EXPECT_EQ(cluster.Startup(tester.GetServerClusterContext()), CHIP_NO_ERROR);
+
+    // Writing the current value (default 10) is a no-op: success, no change report.
+    EXPECT_TRUE(tester.WriteAttribute(CPAttributes::ScanMaxTime::Id, static_cast<uint8_t>(10)).IsSuccess());
+    EXPECT_FALSE(tester.IsAttributeDirty(CPAttributes::ScanMaxTime::Id));
+
+    // Writing a new value reports the change and round-trips through the next read.
+    EXPECT_TRUE(tester.WriteAttribute(CPAttributes::ScanMaxTime::Id, static_cast<uint8_t>(45)).IsSuccess());
+    EXPECT_TRUE(tester.IsAttributeDirty(CPAttributes::ScanMaxTime::Id));
+
+    uint8_t scanMaxTime = 0;
+    ASSERT_EQ(tester.ReadAttribute(CPAttributes::ScanMaxTime::Id, scanMaxTime), CHIP_NO_ERROR);
+    EXPECT_EQ(scanMaxTime, 45u);
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// Spec § Attributes: CacheTimeout (BGS-only) fallback = 120, access = "RW VO". Storage and
+// change reporting are owned by the cluster: a write that changes the value SHALL emit a
+// change report and be visible to the next read, and a write of the unchanged value SHALL
+// NOT report.
+TEST_F(TestCommissioningProxyCluster, TestCacheTimeoutAttribute_DefaultWritableAndChangeReporting)
+{
+    BitMask<Feature> bgs(Feature::kBackgroundScan);
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(bgs), mockTimer);
+    RegisterMocks(cluster);
+
+    // Start with the tester's context so change notifications land in the dirty
+    // list the tester observes.
+    ClusterTester tester(cluster);
+    EXPECT_EQ(cluster.Startup(tester.GetServerClusterContext()), CHIP_NO_ERROR);
+
+    // Default per spec fallback column.
+    uint16_t cacheTimeout = 0;
+    ASSERT_EQ(tester.ReadAttribute(CPAttributes::CacheTimeout::Id, cacheTimeout), CHIP_NO_ERROR);
+    EXPECT_EQ(cacheTimeout, 120u);
+
+    // Writing the current value is a no-op: success, no change report.
+    EXPECT_TRUE(tester.WriteAttribute(CPAttributes::CacheTimeout::Id, static_cast<uint16_t>(120)).IsSuccess());
+    EXPECT_FALSE(tester.IsAttributeDirty(CPAttributes::CacheTimeout::Id));
+
+    // Writing a new value reports the change and round-trips through the next read.
+    EXPECT_TRUE(tester.WriteAttribute(CPAttributes::CacheTimeout::Id, static_cast<uint16_t>(60)).IsSuccess());
+    EXPECT_TRUE(tester.IsAttributeDirty(CPAttributes::CacheTimeout::Id));
+    ASSERT_EQ(tester.ReadAttribute(CPAttributes::CacheTimeout::Id, cacheTimeout), CHIP_NO_ERROR);
+    EXPECT_EQ(cacheTimeout, 60u);
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// Spec § Attributes: NumCachedResults (BGS-only) fallback = 0.
+TEST_F(TestCommissioningProxyCluster, TestNumCachedResultsAttribute_DefaultZero)
+{
+    TestServerClusterContext context;
+    BitMask<Feature> bgs(Feature::kBackgroundScan);
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(bgs), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    uint8_t numCachedResults = 99; // pre-set to non-zero so we know read overwrites
+    ASSERT_EQ(tester.ReadAttribute(CPAttributes::NumCachedResults::Id, numCachedResults), CHIP_NO_ERROR);
+    EXPECT_EQ(numCachedResults, 0u);
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// Spec § Attributes: MaxCachedResults (BGS-only) min 1.  Reads from config.
+TEST_F(TestCommissioningProxyCluster, TestMaxCachedResultsAttribute_ReadsFromConfig)
+{
+    TestServerClusterContext context;
+    BitMask<Feature> bgs(Feature::kBackgroundScan);
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(bgs), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    uint8_t maxCachedResults = 0;
+    ASSERT_EQ(tester.ReadAttribute(CPAttributes::MaxCachedResults::Id, maxCachedResults), CHIP_NO_ERROR);
+    EXPECT_EQ(maxCachedResults, cluster.GetMaxCachedResults());
+    EXPECT_GE(maxCachedResults, 1u); // spec: min 1
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// Spec § Attributes: WiFiBand (WI-only) reflects the configured supported bands.
+TEST_F(TestCommissioningProxyCluster, TestWiFiBandAttribute_ReadsFromConfig)
+{
+    TestServerClusterContext context;
+    chip::BitMask<WiFiBandBitmap> bands;
+    bands.Set(WiFiBandBitmap::k2g4);
+    bands.Set(WiFiBandBitmap::k5g);
+
+    BitMask<Feature> wi(Feature::kWiFiNetworkInterface);
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(wi), mockTimer);
+    RegisterMocks(cluster);
+    cluster.SetSupportedWiFiBands(bands);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    chip::BitMask<WiFiBandBitmap> readBands;
+    ASSERT_EQ(tester.ReadAttribute(CPAttributes::WiFiBand::Id, readBands), CHIP_NO_ERROR);
+    EXPECT_TRUE(readBands.Has(WiFiBandBitmap::k2g4));
+    EXPECT_TRUE(readBands.Has(WiFiBandBitmap::k5g));
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// =============================================================================
+// ProxyConnectRequest Command Tests
+// =============================================================================
+
+// Helper: build a minimal valid ProxyConnectRequest for a single transport.
+static Commands::ProxyConnectRequest::Type MakeConnectRequest(CapabilitiesBitmap transport, uint16_t timeout = 30)
+{
+    Commands::ProxyConnectRequest::Type cmd;
+    cmd.address.SetNull();
+    cmd.transport     = transport;
+    cmd.discriminator = 0;
+    cmd.vendorID      = chip::VendorId::Common;
+    cmd.productID     = 0;
+    cmd.timeout       = timeout;
+    return cmd;
+}
+
+// Establish a proxy session and return its sessionId (via the mock BLE transport).
+static uint16_t OpenSession(ClusterTester & tester)
+{
+    auto conn = tester.Invoke(MakeConnectRequest(CapabilitiesBitmap::kBle));
+    EXPECT_TRUE(conn.IsSuccess());
+    EXPECT_TRUE(conn.response.has_value());
+    return conn.response.has_value() ? conn.response->sessionID : 0;
+}
+
+// Zero transport bits SHALL be rejected.
+TEST_F(TestCommissioningProxyCluster, TestProxyConnectRequest_ZeroTransportBits)
+{
+    TestServerClusterContext context;
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    Commands::ProxyConnectRequest::Type cmd = MakeConnectRequest(static_cast<CapabilitiesBitmap>(0));
+
+    // Spec: "Exactly one transport ... SHALL be selected"; zero bits is an invalid
+    // field, and the spec mandates INVALID_COMMAND for an invalid field.
+    auto result = tester.Invoke(cmd);
+    EXPECT_FALSE(result.IsSuccess());
+    EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidCommand));
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// Multiple transport bits set SHALL be rejected.
+TEST_F(TestCommissioningProxyCluster, TestProxyConnectRequest_MultipleTransportBits)
+{
+    TestServerClusterContext context;
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    Commands::ProxyConnectRequest::Type cmd = MakeConnectRequest(CapabilitiesBitmap::kBle);
+    cmd.transport.Set(CapabilitiesBitmap::kWiFiPAF); // now two bits set
+
+    // Spec: "Exactly one transport ... SHALL be selected"; >1 bit is an invalid
+    // field → INVALID_COMMAND (test plan TC-2.6).
+    auto result = tester.Invoke(cmd);
+    EXPECT_FALSE(result.IsSuccess());
+    EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidCommand));
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// ProxyConnectRequest: Discriminator constraint is "0 to 4095".  A value
+// above 4095 is an invalid field and SHALL return InvalidCommand.
+TEST_F(TestCommissioningProxyCluster, TestProxyConnectRequest_DiscriminatorOutOfRange)
+{
+    TestServerClusterContext context;
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    // Exactly one transport bit set so the single-transport check passes; the
+    // discriminator check runs before the transport-supported check, so this is
+    // independent of which transports are compiled in.
+    Commands::ProxyConnectRequest::Type cmd = MakeConnectRequest(CapabilitiesBitmap::kWiFiPAF);
+    cmd.discriminator                       = chip::kMaxDiscriminatorValue + 1; // one past the spec max
+
+    auto result = tester.Invoke(cmd);
+    EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidCommand));
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// A single reserved bit in transport (a bit outside the spec-defined kBle / kWiFiPAF /
+// kNtl set) SHALL be rejected as a malformed field, not reported as a transport the
+// proxy happens not to support: the ProxyConnectRequest Effect on Receipt reserves
+// InvalidTransportType for a Transport "the proxy cannot support", and gives
+// InvalidCommand for any other invalid field. This is also what the scan commands
+// already return for a reserved bit.
+TEST_F(TestCommissioningProxyCluster, TestProxyConnectRequest_ReservedTransportBitOnly)
+{
+    TestServerClusterContext context;
+    BitMask<Feature> wi(Feature::kWiFiNetworkInterface);
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(wi), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    // 0x01 is a reserved bit (kBle=0x02, kWiFiPAF=0x08, kNtl=0x10).
+    Commands::ProxyConnectRequest::Type cmd = MakeConnectRequest(static_cast<CapabilitiesBitmap>(0x01));
+    auto result                             = tester.Invoke(cmd);
+    EXPECT_FALSE(result.IsSuccess());
+    EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidCommand));
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// The WiFiPAF CapabilitiesBitmap conformance is O.a+, independent of the WI feature: WI
+// gates only the WiFiBand field, which is absent here. So the same request SHALL be
+// accepted whether or not WI is enabled, and SHALL return a sessionId.
+TEST_F(TestCommissioningProxyCluster, TestProxyConnectRequest_WiFiPAFIndependentOfWIFeature)
+{
+    TestServerClusterContext context;
+
+    for (const BitMask<Feature> features : { BitMask<Feature>{}, BitMask<Feature>(Feature::kWiFiNetworkInterface) })
+    {
+        CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
+        RegisterMocks(cluster);
+        EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+        ClusterTester tester(cluster);
+        SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kWiFiPAF);
+
+        auto result = tester.Invoke(MakeConnectRequest(CapabilitiesBitmap::kWiFiPAF));
+        EXPECT_TRUE(result.IsSuccess());
+        ASSERT_TRUE(result.response.has_value());
+        if (result.response.has_value())
+        {
+            EXPECT_EQ(result.response->sessionID, 1u);
+        }
+
+        cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+    }
+}
+
+// WiFiBand present with a non-WiFiPAF transport SHALL return InvalidCommand
+// (the WiFiBand field is only meaningful for the WiFiPAF transport per spec).
+TEST_F(TestCommissioningProxyCluster, TestProxyConnectRequest_WiFiBandWithBleTransport)
+{
+    TestServerClusterContext context;
+    // Enable WI so the cluster has wiFiBand plumbing wired; the rejection here
+    // is driven by the transport != kWiFiPAF check, not the WI feature bit.
+    BitMask<Feature> features(Feature::kWiFiNetworkInterface);
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
+    RegisterMocks(cluster);
+    cluster.SetSupportedWiFiBands(chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k2g4));
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
+
+    Commands::ProxyConnectRequest::Type cmd = MakeConnectRequest(CapabilitiesBitmap::kBle);
+    cmd.wiFiBand.SetValue(chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k2g4));
+
+    auto result = tester.Invoke(cmd);
+    EXPECT_FALSE(result.IsSuccess());
+    EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidCommand));
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// WiFiBand field present with WI feature enabled and band in supported set — SHALL succeed.
+TEST_F(TestCommissioningProxyCluster, TestProxyConnectRequest_WiFiBandWithWIFeature)
+{
+    TestServerClusterContext context;
+    BitMask<Feature> features(Feature::kWiFiNetworkInterface);
+
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
+    RegisterMocks(cluster);
+    cluster.SetSupportedWiFiBands(chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k2g4));
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kWiFiPAF);
+    Commands::ProxyConnectRequest::Type cmd = MakeConnectRequest(CapabilitiesBitmap::kWiFiPAF);
+    cmd.wiFiBand.SetValue(chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k2g4));
+
+    EXPECT_TRUE(tester.Invoke(cmd).IsSuccess());
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// Reserved bits in wiFiBand SHALL be rejected.
+TEST_F(TestCommissioningProxyCluster, TestProxyConnectRequest_ReservedWiFiBandBits)
+{
+    TestServerClusterContext context;
+    BitMask<Feature> features(Feature::kWiFiNetworkInterface);
+
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
+    RegisterMocks(cluster);
+    cluster.SetSupportedWiFiBands(chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k2g4));
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    Commands::ProxyConnectRequest::Type cmd = MakeConnectRequest(CapabilitiesBitmap::kWiFiPAF);
+    // bit 1 (0x02) is reserved.
+    cmd.wiFiBand.SetValue(chip::BitMask<WiFiBandBitmap>(static_cast<WiFiBandBitmap>(0x02)));
+
+    // Impl-defined: the spec mandates INVALID_TRANSPORT_TYPE only for an *unsupported*
+    // WiFiBand; it does not mandate a specific status for reserved WiFiBand bits. This
+    // asserts current behavior (INVALID_COMMAND for a malformed field).
+    auto result = tester.Invoke(cmd);
+    EXPECT_FALSE(result.IsSuccess());
+    EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidCommand));
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// wiFiBand not in the proxy's supported bands SHALL return InvalidTransportType.
+TEST_F(TestCommissioningProxyCluster, TestProxyConnectRequest_WiFiBandNotInSupportedBands)
+{
+    TestServerClusterContext context;
+    BitMask<Feature> features(Feature::kWiFiNetworkInterface);
+    // Proxy only supports 2.4 GHz.
+
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
+    RegisterMocks(cluster);
+    cluster.SetSupportedWiFiBands(chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k2g4));
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    Commands::ProxyConnectRequest::Type cmd = MakeConnectRequest(CapabilitiesBitmap::kWiFiPAF);
+    // Request 5 GHz — not in supported bands.
+    cmd.wiFiBand.SetValue(chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k5g));
+
+    auto result = tester.Invoke(cmd);
+    EXPECT_FALSE(result.IsSuccess());
+    EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidTransportType));
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// Whenever the proxy advertises kBle, ProxyConnectRequest with kBle SHALL succeed and
+// return a SessionID, and the cluster state SHALL move to kState_CPConnected.
+TEST_F(TestCommissioningProxyCluster, TestProxyConnectRequest_StateTransitionOnSuccess)
+{
+    TestServerClusterContext context;
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    EXPECT_EQ(cluster.GetCPState(), CommissioningProxyCluster::kState_CPDisconnected);
+
+    ClusterTester tester(cluster);
+    SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
+    auto result = tester.Invoke(MakeConnectRequest(CapabilitiesBitmap::kBle));
+    EXPECT_TRUE(result.IsSuccess());
+    ASSERT_TRUE(result.response.has_value());
+    if (result.response.has_value())
+    {
+        EXPECT_EQ(result.response->sessionID, 1u);
+    }
+
+    EXPECT_EQ(cluster.GetCPState(), CommissioningProxyCluster::kState_CPConnected);
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// A failed ProxyConnectRequest SHALL NOT change the cluster state.
+TEST_F(TestCommissioningProxyCluster, TestProxyConnectRequest_StateUnchangedOnFailure)
+{
+    TestServerClusterContext context;
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    // A reserved transport bit (0x01) is always rejected, whatever transports the build
+    // registers, so this is a build-independent connect failure that must not transition
+    // state.
+    EXPECT_FALSE(tester.Invoke(MakeConnectRequest(static_cast<CapabilitiesBitmap>(0x01))).IsSuccess());
+
+    EXPECT_EQ(cluster.GetCPState(), CommissioningProxyCluster::kState_CPDisconnected);
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// Per the ProxyConnectRequest Effect on Receipt: if the number of active sessions
+// has reached the value of the MaxSessions attribute, a RESOURCE_EXHAUSTED status
+// SHALL be returned.  Enforced generically by the cluster from
+// GetActiveSessionCount() vs the configured MaxSessions, so every transport inherits the
+// behaviour without having to remember the check itself.
+TEST_F(TestCommissioningProxyCluster, TestProxyConnectRequest_ResourceExhaustedAtMaxSessions)
+{
+    TestServerClusterContext context;
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+
+    // Fill the session pool, then check the next connect is refused. A pending connect
+    // also counts toward the active-session total, so the last slot is taken that way.
+    for (uint8_t i = 0; i < CHIP_CONFIG_COMMISSIONING_PROXY_MAX_SESSIONS - 1; i++)
+    {
+        EXPECT_TRUE(tester.Invoke(MakeConnectRequest(CapabilitiesBitmap::kBle)).IsSuccess());
+    }
+    mockBle.SetConnectPending(true);
+
+    auto result = tester.Invoke(MakeConnectRequest(CapabilitiesBitmap::kBle));
+    EXPECT_FALSE(result.IsSuccess());
+    EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::ResourceExhausted));
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// Below MaxSessions: the cluster-level pre-check does not reject; the
+// request is forwarded to the transport driver and (for the mock) succeeds.
+TEST_F(TestCommissioningProxyCluster, TestProxyConnectRequest_BelowMaxSessionsSucceeds)
+{
+    SKIP_IF_MAX_SESSIONS_BELOW(2);
+
+    TestServerClusterContext context;
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
+
+    // First connect establishes one session, leaving at least one slot free.
+    EXPECT_TRUE(tester.Invoke(MakeConnectRequest(CapabilitiesBitmap::kBle)).IsSuccess());
+
+    // Second connect is still below MaxSessions, so the gate passes and it succeeds.
+    // (The MaxSessions == 1 exhaustion case is covered by _ResourceExhaustedAtMaxSessions.)
+    EXPECT_TRUE(tester.Invoke(MakeConnectRequest(CapabilitiesBitmap::kBle)).IsSuccess());
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// Per the ProxyConnectRequest Effect on Receipt: if Timeout expires, the connection
+// attempt is terminated and a TIMEOUT status SHALL be returned.
+TEST_F(TestCommissioningProxyCluster, TestProxyConnectRequest_TransportTimeout_Propagated)
+{
+    TestServerClusterContext context;
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    // The transport's connect times out; the cluster SHALL surface TIMEOUT.
+    mockBle.SetConnectStatus(Protocols::InteractionModel::Status::Timeout);
+
+    ClusterTester tester(cluster);
+    auto result = tester.Invoke(MakeConnectRequest(CapabilitiesBitmap::kBle));
+    EXPECT_FALSE(result.IsSuccess());
+    EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::Timeout));
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// =============================================================================
+// ProxyDisconnectRequest Command Tests
+// =============================================================================
+
+// Disconnecting with the sessionId the connect returned SHALL succeed, and with no
+// session left the cluster state SHALL revert to kState_CPDisconnected.
+TEST_F(TestCommissioningProxyCluster, TestProxyDisconnectRequest_StateTransitionToDisconnected)
+{
+    TestServerClusterContext context;
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
+
+    uint16_t sid = OpenSession(tester);
+    EXPECT_EQ(cluster.GetCPState(), CommissioningProxyCluster::kState_CPConnected);
+
+    Commands::ProxyDisconnectRequest::Type cmd;
+    cmd.sessionID.SetNonNull(sid);
+    EXPECT_TRUE(tester.Invoke(cmd).IsSuccess());
+
+    // State SHALL be Disconnected after a successful disconnect.
+    EXPECT_EQ(cluster.GetCPState(), CommissioningProxyCluster::kState_CPDisconnected);
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// With MaxSessions > 1, disconnecting one of several active sessions SHALL NOT
+// transition the cluster to disconnected; only the final disconnect (no sessions
+// remaining) SHALL do so.
+TEST_F(TestCommissioningProxyCluster, TestProxyDisconnectRequest_MultiSessionStateTransition)
+{
+    SKIP_IF_MAX_SESSIONS_BELOW(2);
+
+    TestServerClusterContext context;
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+
+    // Open two sessions and capture their (distinct) session IDs.
+    auto r1 = tester.Invoke(MakeConnectRequest(CapabilitiesBitmap::kBle));
+    ASSERT_TRUE(r1.IsSuccess());
+    ASSERT_TRUE(r1.response.has_value());
+    auto r2 = tester.Invoke(MakeConnectRequest(CapabilitiesBitmap::kBle));
+    ASSERT_TRUE(r2.IsSuccess());
+    ASSERT_TRUE(r2.response.has_value());
+    EXPECT_EQ(cluster.GetCPState(), CommissioningProxyCluster::kState_CPConnected);
+
+    Commands::ProxyDisconnectRequest::Type cmd;
+
+    // First disconnect: one session remains, so state SHALL stay Connected.
+    if (r1.response.has_value())
+    {
+        cmd.sessionID.SetNonNull(r1.response->sessionID);
+    }
+    EXPECT_TRUE(tester.Invoke(cmd).IsSuccess());
+    EXPECT_EQ(cluster.GetCPState(), CommissioningProxyCluster::kState_CPConnected);
+
+    // Second disconnect: no sessions remain, so state SHALL revert to Disconnected.
+    if (r2.response.has_value())
+    {
+        cmd.sessionID.SetNonNull(r2.response->sessionID);
+    }
+    EXPECT_TRUE(tester.Invoke(cmd).IsSuccess());
+    EXPECT_EQ(cluster.GetCPState(), CommissioningProxyCluster::kState_CPDisconnected);
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// When the transport fails to disconnect, the command SHALL fail and the cluster
+// state SHALL remain Connected (session not cleaned up).
+TEST_F(TestCommissioningProxyCluster, TestProxyDisconnectRequest_TransportFailurePreservesState)
+{
+    TestServerClusterContext context;
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+
+    auto connectResult = tester.Invoke(MakeConnectRequest(CapabilitiesBitmap::kBle));
+    ASSERT_TRUE(connectResult.IsSuccess());
+    ASSERT_TRUE(connectResult.response.has_value());
+    EXPECT_EQ(cluster.GetCPState(), CommissioningProxyCluster::kState_CPConnected);
+
+    // The transport rejects the disconnect.
+    mockBle.SetDisconnectStatus(Protocols::InteractionModel::Status::Failure);
+
+    Commands::ProxyDisconnectRequest::Type cmd;
+    if (connectResult.response.has_value())
+    {
+        cmd.sessionID.SetNonNull(connectResult.response->sessionID);
+    }
+    EXPECT_FALSE(tester.Invoke(cmd).IsSuccess());
+
+    // State SHALL remain Connected since the transport rejected the disconnect.
+    EXPECT_EQ(cluster.GetCPState(), CommissioningProxyCluster::kState_CPConnected);
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// A null SessionID with a pending connect SHALL return Success and cancel the connect.
+TEST_F(TestCommissioningProxyCluster, TestProxyDisconnectRequest_CancelPending_Success)
+{
+    TestServerClusterContext context;
+    BitMask<Feature> features(Feature::kWiFiNetworkInterface);
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+
+    // A connect is pending on the BLE transport, owned by fabric 2.
+    mockBle.SetPendingConnectFabric(2);
+
+    tester.SetFabricIndex(2);
+    Commands::ProxyDisconnectRequest::Type cmd;
+    cmd.sessionID.SetNull();
+    EXPECT_TRUE(tester.Invoke(cmd).IsSuccess());
+    EXPECT_TRUE(mockBle.CancelCalled());
+    EXPECT_EQ(mockBle.LastCancelFabric(), 2);
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// A null SessionID with no ProxyConnectRequest in flight (e.g. already
+// connected, or never started) has no pending connect to cancel.
+TEST_F(TestCommissioningProxyCluster, TestProxyDisconnectRequest_CancelPending_AlreadyConnected)
+{
+    TestServerClusterContext context;
+    BitMask<Feature> features(Feature::kWiFiNetworkInterface);
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+
+    // CancelPendingConnect inherits InvalidInState from the transport driver (no pending connect).
+    Commands::ProxyDisconnectRequest::Type cmd;
+    cmd.sessionID.SetNull();
+    auto result = tester.Invoke(cmd);
+    EXPECT_FALSE(result.IsSuccess());
+    EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidInState));
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// A null SessionID from the wrong fabric SHALL return NotFound (fabric isolation).
+TEST_F(TestCommissioningProxyCluster, TestProxyDisconnectRequest_CancelPending_WrongFabric)
+{
+    TestServerClusterContext context;
+    BitMask<Feature> features(Feature::kWiFiNetworkInterface);
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+
+    // A connect is pending on the BLE transport, owned by fabric 1.
+    mockBle.SetPendingConnectFabric(1);
+
+    Commands::ProxyDisconnectRequest::Type cmd;
+    cmd.sessionID.SetNull();
+
+    // Fabric 2 tries to cancel fabric 1's pending connect — SHALL be rejected.
+    tester.SetFabricIndex(2);
+    auto result = tester.Invoke(cmd);
+    EXPECT_FALSE(result.IsSuccess());
+    EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::NotFound));
+
+    // Fabric 1 (the owner) cancels its own pending connect — SHALL succeed.
+    tester.SetFabricIndex(1);
+    EXPECT_TRUE(tester.Invoke(cmd).IsSuccess());
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// Per the ProxyDisconnectRequest Effect on Receipt: if no transport connection
+// with the specified SessionId exists (or the connection's fabric does not match
+// the sending fabric), the command SHALL be rejected with NOT_FOUND.  The cluster
+// returns NotFound; cluster must propagate.
+TEST_F(TestCommissioningProxyCluster, TestProxyDisconnectRequest_UnknownSession_NotFound)
+{
+    TestServerClusterContext context;
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    Commands::ProxyDisconnectRequest::Type cmd;
+    cmd.sessionID.SetNonNull(1234); // no such session registered
+
+    auto result = tester.Invoke(cmd);
+    EXPECT_FALSE(result.IsSuccess());
+    EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::NotFound));
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// =============================================================================
+// ProxyScanRequest Command Tests
+// =============================================================================
+
+// Zero transport bits SHALL be rejected.
+TEST_F(TestCommissioningProxyCluster, TestProxyScanRequest_ZeroTransport)
+{
+    TestServerClusterContext context;
+
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    Commands::ProxyScanRequest::Type command;
+    command.transport = static_cast<CapabilitiesBitmap>(0);
+
+    // Impl-defined: the ProxyScanRequest spec defines only a BUSY status; it mandates
+    // no specific status for a zero/malformed transport. This asserts current behavior.
+    auto result = tester.Invoke(Commands::ProxyScanRequest::Id, command);
+    EXPECT_FALSE(result.IsSuccess());
+    EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidCommand));
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// Reserved bits in transport SHALL be rejected.
+TEST_F(TestCommissioningProxyCluster, TestProxyScanRequest_ReservedTransportBits)
+{
+    TestServerClusterContext context;
+
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    // kWiFiPAF(0x08) | reserved(0x01) = 0x09 contains a reserved bit.
+    Commands::ProxyScanRequest::Type command;
+    command.transport = static_cast<CapabilitiesBitmap>(0x09);
+
+    // Impl-defined: the ProxyScanRequest spec defines only a BUSY status; it mandates
+    // no specific status for reserved transport bits. This asserts current behavior.
+    auto result = tester.Invoke(Commands::ProxyScanRequest::Id, command);
+    EXPECT_FALSE(result.IsSuccess());
+    EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidCommand));
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// Whenever the proxy advertises kBle in the Transport attribute,
+// ProxyScanRequest with kBle
+// SHALL succeed regardless of which Feature bits the cluster was constructed
+// with.  Skips when the build does not include BLE.
+TEST_F(TestCommissioningProxyCluster, TestProxyScanRequest_BleNoFeaturesSucceeds)
+{
+    TestServerClusterContext context;
+
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
+
+    Commands::ProxyScanRequest::Type command;
+    command.transport = CapabilitiesBitmap::kBle;
+
+    EXPECT_TRUE(tester.Invoke(Commands::ProxyScanRequest::Id, command).IsSuccess());
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// ProxyScanRequest with both kBle and kWiFiPAF in a single transport mask
+// (with the WI feature enabled) SHALL succeed.  Skips when either transport
+// is missing from the build.
+TEST_F(TestCommissioningProxyCluster, TestProxyScanRequest_BleAndWiFiPAFTogether)
+{
+    TestServerClusterContext context;
+    BitMask<Feature> features(Feature::kWiFiNetworkInterface);
+
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
+    RegisterMocks(cluster);
+    cluster.SetSupportedWiFiBands(chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k2g4));
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
+    SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kWiFiPAF);
+
+    Commands::ProxyScanRequest::Type command;
+    command.transport.Set(CapabilitiesBitmap::kBle);
+    command.transport.Set(CapabilitiesBitmap::kWiFiPAF);
+
+    // Each mock transport contributes 2 results; the aggregator SHALL combine both
+    // sub-scans into a single ProxyScanResponse and report the total count.
+    auto result = tester.Invoke(command);
+    ASSERT_TRUE(result.IsSuccess());
+    ASSERT_TRUE(result.response.has_value());
+    if (result.response.has_value())
+    {
+        EXPECT_EQ(result.response->numberOfResults, 4u);
+        size_t listCount = 0;
+        auto iter        = result.response->proxyScanResult.begin();
+        while (iter.Next())
+        {
+            ++listCount;
+        }
+        EXPECT_EQ(iter.GetStatus(), CHIP_NO_ERROR);
+        EXPECT_EQ(listCount, 4u);
+    }
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// The cluster registers each sub-scan only after starting it, so a transport reporting
+// synchronously from inside its own Scan() satisfies the expected count while later
+// transports have yet to be counted. Emitting there would answer the commissioner with a
+// ProxyScanResponse missing every transport the cluster had not reached yet.
+TEST_F(TestCommissioningProxyCluster, TestProxyScanRequest_SyncContributorWaitsForRemainingSubScans)
+{
+    TestServerClusterContext context;
+
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
+    SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kWiFiPAF);
+
+    // BLE is scanned first and reports later, as a real driver does from its own scan
+    // timer; PAF then reports synchronously from inside Scan().
+    mockBle.SetAutoContribute(false);
+    mockPaf.SetAutoContribute(true);
+
+    Commands::ProxyScanRequest::Type command;
+    command.transport.Set(CapabilitiesBitmap::kBle);
+    command.transport.Set(CapabilitiesBitmap::kWiFiPAF);
+    [[maybe_unused]] auto pending = tester.Invoke(command);
+
+    // PAF's synchronous report must not have closed the aggregation on its own.
+    EXPECT_FALSE(tester.GetCommandHandler().HasResponse());
+
+    // BLE reports; with every sub-scan in, the response carries both transports' results.
+    mockBle.ContributeScanResults();
+    ASSERT_TRUE(tester.GetCommandHandler().HasResponse());
+    Commands::ProxyScanResponse::DecodableType response;
+    ASSERT_EQ(tester.GetCommandHandler().DecodeResponse(response), CHIP_NO_ERROR);
+    EXPECT_EQ(response.numberOfResults, 4u);
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// The kWiFiPAF transport bit is O.a+, independent of WI: WI gates only the wiFiBands
+// field, which is absent here. So the same scan SHALL be accepted either way.
+TEST_F(TestCommissioningProxyCluster, TestProxyScanRequest_WiFiPAFIndependentOfWIFeature)
+{
+    TestServerClusterContext context;
+
+    for (const BitMask<Feature> features : { BitMask<Feature>{}, BitMask<Feature>(Feature::kWiFiNetworkInterface) })
+    {
+        CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
+        RegisterMocks(cluster);
+        EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+        ClusterTester tester(cluster);
+        SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kWiFiPAF);
+
+        Commands::ProxyScanRequest::Type command;
+        command.transport = CapabilitiesBitmap::kWiFiPAF;
+        // No wiFiBands — skips the band validation entirely.
+
+        EXPECT_TRUE(tester.Invoke(Commands::ProxyScanRequest::Id, command).IsSuccess());
+
+        cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+    }
+}
+
+// WiFiBands field present without the WI feature is rejected. The kWiFiPAF transport
+// itself is supported without WI; the rejection is driven solely by the
+// wiFiBands-requires-WI guard. Impl-defined status: the spec does not mandate a
+// specific status for a WI-conformance field sent without WI; this asserts current
+// behavior (INVALID_COMMAND).
+TEST_F(TestCommissioningProxyCluster, TestProxyScanRequest_WiFiBandWithoutWIFeature)
+{
+    TestServerClusterContext context;
+
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kWiFiPAF);
+
+    Commands::ProxyScanRequest::Type command;
+    command.transport = CapabilitiesBitmap::kWiFiPAF;
+    command.wiFiBands.SetValue(chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k2g4));
+
+    auto result = tester.Invoke(Commands::ProxyScanRequest::Id, command);
+    EXPECT_FALSE(result.IsSuccess());
+    EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidCommand));
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// Reserved bits in wiFiBands SHALL be rejected.
+TEST_F(TestCommissioningProxyCluster, TestProxyScanRequest_ReservedWiFiBandBits)
+{
+    TestServerClusterContext context;
+    BitMask<Feature> features(Feature::kWiFiNetworkInterface);
+
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
+    RegisterMocks(cluster);
+    cluster.SetSupportedWiFiBands(chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k2g4));
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    // k2g4=0x01, k5g=0x04 are valid; bit 1 (0x02) is reserved.
+    Commands::ProxyScanRequest::Type command;
+    command.transport = CapabilitiesBitmap::kWiFiPAF;
+    command.wiFiBands.SetValue(chip::BitMask<WiFiBandBitmap>(static_cast<WiFiBandBitmap>(0x02)));
+
+    // Impl-defined: the spec mandates INVALID_TRANSPORT_TYPE only for unsupported
+    // bands, not for reserved WiFiBand bits. This asserts current behavior.
+    auto result = tester.Invoke(Commands::ProxyScanRequest::Id, command);
+    EXPECT_FALSE(result.IsSuccess());
+    EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidCommand));
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// wiFiBands containing bits not in the proxy's supported bands SHALL return
+// InvalidTransportType.
+TEST_F(TestCommissioningProxyCluster, TestProxyScanRequest_WiFiBandNotInSupportedBands)
+{
+    TestServerClusterContext context;
+    BitMask<Feature> features(Feature::kWiFiNetworkInterface);
+    // Proxy only supports 2.4 GHz.
+
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
+    RegisterMocks(cluster);
+    cluster.SetSupportedWiFiBands(chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k2g4));
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    // Request 5 GHz — not in supported bands.
+    Commands::ProxyScanRequest::Type command;
+    command.transport = CapabilitiesBitmap::kWiFiPAF;
+    command.wiFiBands.SetValue(chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k5g));
+
+    auto result = tester.Invoke(Commands::ProxyScanRequest::Id, command);
+    EXPECT_FALSE(result.IsSuccess());
+    EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidTransportType));
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// kWiFiPAF with WI feature and a wiFiBands value within supported bands — SHALL succeed.
+TEST_F(TestCommissioningProxyCluster, TestProxyScanRequest_ValidWiFiBandInSupportedBands)
+{
+    TestServerClusterContext context;
+    BitMask<Feature> features(Feature::kWiFiNetworkInterface);
+    // Proxy supports both 2.4 GHz and 5 GHz.
+    chip::BitMask<WiFiBandBitmap> bothBands;
+    bothBands.Set(WiFiBandBitmap::k2g4);
+    bothBands.Set(WiFiBandBitmap::k5g);
+
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
+    RegisterMocks(cluster);
+    cluster.SetSupportedWiFiBands(bothBands);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kWiFiPAF);
+    Commands::ProxyScanRequest::Type command;
+    command.transport = CapabilitiesBitmap::kWiFiPAF;
+    command.wiFiBands.SetValue(chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k2g4));
+
+    EXPECT_TRUE(tester.Invoke(Commands::ProxyScanRequest::Id, command).IsSuccess());
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// =============================================================================
+// ProxyMessageRequest Command Tests
+// =============================================================================
+
+// ProxyMessageRequest with a non-null message SHALL succeed and return a
+// ProxyMessageResponse carrying the same sessionId.
+TEST_F(TestCommissioningProxyCluster, TestProxyMessageRequest_WithMessage)
+{
+    TestServerClusterContext context;
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    uint16_t sid = OpenSession(tester);
+
+    static const uint8_t kMsg[] = { 0x01, 0x02, 0x03, 0x04 };
+    Commands::ProxyMessageRequest::Type cmd;
+    cmd.sessionID       = sid;
+    cmd.responseTimeout = 5;
+    cmd.message.SetNonNull(chip::ByteSpan(kMsg, sizeof(kMsg)));
+
+    // The mock transport delivers an immediate (null) commissionee reply.
+    auto result = tester.Invoke(cmd);
+    EXPECT_TRUE(result.IsSuccess());
+    ASSERT_TRUE(result.response.has_value());
+    if (result.response.has_value())
+    {
+        EXPECT_EQ(result.response->sessionID, sid);
+    }
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// A null ProxyMessageRequest.message SHALL succeed and
+// return a ProxyMessageResponse with a null message (no data from commissionee).
+TEST_F(TestCommissioningProxyCluster, TestProxyMessageRequest_NullMessage_Poll)
+{
+    TestServerClusterContext context;
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    uint16_t sid = OpenSession(tester);
+
+    Commands::ProxyMessageRequest::Type cmd;
+    cmd.sessionID       = sid;
+    cmd.responseTimeout = 5;
+    cmd.message.SetNull();
+
+    auto result = tester.Invoke(cmd);
+    EXPECT_TRUE(result.IsSuccess());
+    ASSERT_TRUE(result.response.has_value());
+    if (result.response.has_value())
+    {
+        EXPECT_EQ(result.response->sessionID, sid);
+        // Null response message signals no pending data from commissionee.
+        EXPECT_TRUE(result.response->message.IsNull());
+    }
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// Per the ProxyMessageRequest Effect on Receipt: if no transport connection with
+// the specified SessionID exists (or the fabric does not match), the command SHALL
+// be rejected with NOT_FOUND. The cluster's session table enforces this.
+TEST_F(TestCommissioningProxyCluster, TestProxyMessageRequest_UnknownSession_NotFound)
+{
+    TestServerClusterContext context;
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+
+    static const uint8_t kMsg[] = { 0xFF };
+    Commands::ProxyMessageRequest::Type cmd;
+    cmd.sessionID       = 9999; // no such session
+    cmd.responseTimeout = 5;
+    cmd.message.SetNonNull(chip::ByteSpan(kMsg, sizeof(kMsg)));
+
+    auto result = tester.Invoke(cmd);
+    EXPECT_FALSE(result.IsSuccess());
+    EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::NotFound));
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// Per the ProxyMessageRequest Effect on Receipt: if another ProxyMessageRequest
+// referencing the same SessionId is still outstanding, the command SHALL be
+// rejected with BUSY. The cluster's session manager enforces this.
+TEST_F(TestCommissioningProxyCluster, TestProxyMessageRequest_DuplicateRequest_Busy)
+{
+    TestServerClusterContext context;
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    uint16_t sid = OpenSession(tester);
+
+    // Leave the first request pending (no commissionee reply), so the second one
+    // for the same session hits the BUSY path.
+    mockBle.SetAutoRespond(false);
+
+    static const uint8_t kMsg[] = { 0xFF };
+    Commands::ProxyMessageRequest::Type cmd;
+    cmd.sessionID       = sid;
+    cmd.responseTimeout = 5;
+    cmd.message.SetNonNull(chip::ByteSpan(kMsg, sizeof(kMsg)));
+
+    [[maybe_unused]] auto pending = tester.Invoke(cmd); // first request: stays pending
+    auto result                   = tester.Invoke(cmd);
+    EXPECT_FALSE(result.IsSuccess());
+    EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::Busy));
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// =============================================================================
+// ProxyBackgroundScanStartRequest — parameter validation tests
+// =============================================================================
+
+// Helper: build a minimal valid ProxyBackgroundScanStartRequest.
+static Commands::ProxyBackGroundScanStartRequest::Type
+MakeBgScanStartRequest(CapabilitiesBitmap transport, uint16_t timeout = 30,
+                       chip::Optional<chip::BitMask<WiFiBandBitmap>> wiFiBands = chip::NullOptional)
+{
+    Commands::ProxyBackGroundScanStartRequest::Type cmd;
+    cmd.transport = transport;
+    cmd.timeout   = timeout;
+    cmd.wiFiBands = wiFiBands;
+    return cmd;
+}
+
+// transport=0 SHALL be rejected.
+TEST_F(TestCommissioningProxyCluster, TestBgScanStart_ZeroTransport)
+{
+    TestServerClusterContext context;
+    BitMask<Feature> features(Feature::kBackgroundScan, Feature::kWiFiNetworkInterface);
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    // Impl-defined: the spec mandates INVALID_TRANSPORT_TYPE only for an unsupported
+    // transport/band on BgScanStart; it does not mandate a status for a zero transport.
+    // This asserts current behavior.
+    auto result = tester.Invoke(MakeBgScanStartRequest(static_cast<CapabilitiesBitmap>(0)));
+    EXPECT_FALSE(result.IsSuccess());
+    EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidCommand));
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// Reserved bits in transport SHALL be rejected.
+TEST_F(TestCommissioningProxyCluster, TestBgScanStart_ReservedTransportBits)
+{
+    TestServerClusterContext context;
+    BitMask<Feature> features(Feature::kBackgroundScan, Feature::kWiFiNetworkInterface);
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    // CapabilitiesBitmap: kBle=0x02, kWiFiPAF=0x08; all other bits are reserved.
+    // 0x09 = kWiFiPAF(0x08) | reserved(0x01) → contains a reserved bit.
+    // Impl-defined: the spec mandates no specific status for reserved transport bits
+    // on BgScanStart. This asserts current behavior.
+    auto result = tester.Invoke(MakeBgScanStartRequest(static_cast<CapabilitiesBitmap>(0x09)));
+    EXPECT_FALSE(result.IsSuccess());
+    EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidCommand));
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// kBle is not gated by any Feature bit in the spec; whenever the proxy
+// advertises kBle in the Transport attribute, ProxyBackGroundScanStartRequest
+// with kBle SHALL succeed (only the BGS feature is required for the command
+// to be accepted at all).
+TEST_F(TestCommissioningProxyCluster, TestBgScanStart_BleNoExtraFeaturesSucceeds)
+{
+    TestServerClusterContext context;
+    BitMask<Feature> features(Feature::kBackgroundScan);
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
+
+    EXPECT_TRUE(tester.Invoke(MakeBgScanStartRequest(CapabilitiesBitmap::kBle)).IsSuccess());
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// kWiFiPAF without WI feature SHALL be accepted: the WiFiPAF transport bit is
+// O.a+, independent of WI. WI only gates the wiFiBands field (absent here).
+TEST_F(TestCommissioningProxyCluster, TestBgScanStart_WiFiPAFWithoutWIFeature)
+{
+    TestServerClusterContext context;
+    BitMask<Feature> features(Feature::kBackgroundScan); // no kWiFiNetworkInterface
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kWiFiPAF);
+
+    EXPECT_TRUE(tester.Invoke(MakeBgScanStartRequest(CapabilitiesBitmap::kWiFiPAF)).IsSuccess());
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// wiFiBands with reserved bits SHALL be rejected.
+TEST_F(TestCommissioningProxyCluster, TestBgScanStart_ReservedWiFiBandBits)
+{
+    TestServerClusterContext context;
+    BitMask<Feature> features(Feature::kBackgroundScan, Feature::kWiFiNetworkInterface);
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    // k2g4=0x01, k5g=0x04 are valid; bit 1 (0x02) is reserved.
+    auto cmd = MakeBgScanStartRequest(CapabilitiesBitmap::kWiFiPAF, 30,
+                                      chip::MakeOptional(chip::BitMask<WiFiBandBitmap>(static_cast<WiFiBandBitmap>(0x02))));
+    // Impl-defined: the spec mandates INVALID_TRANSPORT_TYPE only for unsupported
+    // bands, not reserved WiFiBand bits. This asserts current behavior.
+    auto result = tester.Invoke(cmd);
+    EXPECT_FALSE(result.IsSuccess());
+    EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidCommand));
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// kWiFiPAF with a wiFiBands field but no WI feature is rejected. The kWiFiPAF
+// transport itself is supported without WI; the rejection is driven solely by the
+// wiFiBands-requires-WI guard. Impl-defined status: the spec does not mandate a
+// specific status for a WI-conformance field sent without WI; this asserts current
+// behavior (INVALID_COMMAND).
+TEST_F(TestCommissioningProxyCluster, TestBgScanStart_WiFiPAFAndBandWithoutWIFeature)
+{
+    TestServerClusterContext context;
+    BitMask<Feature> features(Feature::kBackgroundScan); // no kWiFiNetworkInterface
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kWiFiPAF);
+
+    auto cmd    = MakeBgScanStartRequest(CapabilitiesBitmap::kWiFiPAF, 30,
+                                         chip::MakeOptional(chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k2g4)));
+    auto result = tester.Invoke(cmd);
+    EXPECT_FALSE(result.IsSuccess());
+    EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidCommand));
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// Valid kWiFiPAF with wiFiBands inside the supported set — SHALL succeed, for a single
+// band and for a multi-bit mask ("Multiple frequency bands can be selected for the scan").
+TEST_F(TestCommissioningProxyCluster, TestBgScanStart_ValidWiFiPAFBands)
+{
+    TestServerClusterContext context;
+    chip::BitMask<WiFiBandBitmap> bothBands;
+    bothBands.Set(WiFiBandBitmap::k2g4);
+    bothBands.Set(WiFiBandBitmap::k5g);
+    BitMask<Feature> features(Feature::kBackgroundScan, Feature::kWiFiNetworkInterface);
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
+    RegisterMocks(cluster);
+    cluster.SetSupportedWiFiBands(bothBands);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kWiFiPAF);
+
+    EXPECT_TRUE(tester
+                    .Invoke(MakeBgScanStartRequest(CapabilitiesBitmap::kWiFiPAF, 30,
+                                                   chip::MakeOptional(chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k2g4))))
+                    .IsSuccess());
+    EXPECT_TRUE(tester.Invoke(MakeBgScanStartRequest(CapabilitiesBitmap::kWiFiPAF, 30, chip::MakeOptional(bothBands))).IsSuccess());
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// kWiFiPAF with a WiFiBand bit not in GetSupportedWiFiBands() SHALL return INVALID_TRANSPORT_TYPE.
+// Proxy only supports 2.4 GHz; requesting 5 GHz must be rejected.
+TEST_F(TestCommissioningProxyCluster, TestBgScanStart_UnsupportedWiFiBand)
+{
+    TestServerClusterContext context;
+    BitMask<Feature> features(Feature::kBackgroundScan, Feature::kWiFiNetworkInterface);
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
+    RegisterMocks(cluster);
+    cluster.SetSupportedWiFiBands(chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k2g4));
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    auto cmd    = MakeBgScanStartRequest(CapabilitiesBitmap::kWiFiPAF, 30,
+                                         chip::MakeOptional(chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k5g)));
+    auto result = tester.Invoke(cmd);
+    EXPECT_FALSE(result.IsSuccess());
+    EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidTransportType));
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// =============================================================================
+// ProxyBackgroundScanStopRequest — parameter validation tests
+// =============================================================================
+
+// Helper: build a ProxyBackgroundScanStopRequest.
+static Commands::ProxyBackGroundScanStopRequest::Type
+MakeBgScanStopRequest(CapabilitiesBitmap transport, chip::Optional<chip::BitMask<WiFiBandBitmap>> wiFiBands = chip::NullOptional)
+{
+    Commands::ProxyBackGroundScanStopRequest::Type cmd;
+    cmd.transport = transport;
+    cmd.wiFiBands = wiFiBands;
+    return cmd;
+}
+
+// transport=0 and no wiFiBands SHALL be rejected (nothing to stop).
+TEST_F(TestCommissioningProxyCluster, TestBgScanStop_ZeroTransportNoWiFiBands)
+{
+    TestServerClusterContext context;
+    BitMask<Feature> features(Feature::kBackgroundScan, Feature::kWiFiNetworkInterface);
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    // Impl-defined: the BgScanStop spec defines only NOT_FOUND and SUCCESS; it mandates
+    // no specific status for an empty (transport=0, no bands) request. This asserts
+    // current behavior.
+    auto result = tester.Invoke(MakeBgScanStopRequest(static_cast<CapabilitiesBitmap>(0)));
+    EXPECT_FALSE(result.IsSuccess());
+    EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidCommand));
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// Reserved bits in transport SHALL be rejected.
+TEST_F(TestCommissioningProxyCluster, TestBgScanStop_ReservedTransportBits)
+{
+    TestServerClusterContext context;
+    BitMask<Feature> features(Feature::kBackgroundScan, Feature::kWiFiNetworkInterface);
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    // CapabilitiesBitmap: kBle=0x02, kWiFiPAF=0x08; all other bits are reserved.
+    // 0x09 = kWiFiPAF(0x08) | reserved(0x01) → contains a reserved bit.
+    // Impl-defined: the BgScanStop spec mandates no specific status for reserved
+    // transport bits. This asserts current behavior.
+    auto result = tester.Invoke(MakeBgScanStopRequest(static_cast<CapabilitiesBitmap>(0x09)));
+    EXPECT_FALSE(result.IsSuccess());
+    EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidCommand));
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// kBle is not gated by any Feature bit in the spec; whenever the proxy
+// advertises kBle in the Transport attribute, ProxyBackGroundScanStopRequest
+// with kBle passes the cluster-level transport validation and is forwarded to
+// the transport driver.  The mock returns Success, mirroring the WiFiPAF
+// positive case below.  (NotFound for an unrecognised fabric is a
+// transport-level concern and is covered by the platform transport's own tests.)
+TEST_F(TestCommissioningProxyCluster, TestBgScanStop_BleValid)
+{
+    TestServerClusterContext context;
+    BitMask<Feature> features(Feature::kBackgroundScan);
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
+
+    EXPECT_TRUE(tester.Invoke(MakeBgScanStopRequest(CapabilitiesBitmap::kBle)).IsSuccess());
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// kWiFiPAF without WI feature SHALL be accepted: the WiFiPAF transport bit is
+// O.a+, independent of WI. WI only gates the wiFiBands field (absent here).
+TEST_F(TestCommissioningProxyCluster, TestBgScanStop_WiFiPAFWithoutWIFeature)
+{
+    TestServerClusterContext context;
+    BitMask<Feature> features(Feature::kBackgroundScan); // no kWiFiNetworkInterface
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kWiFiPAF);
+
+    EXPECT_TRUE(tester.Invoke(MakeBgScanStopRequest(CapabilitiesBitmap::kWiFiPAF)).IsSuccess());
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// wiFiBands with reserved bits SHALL be rejected.
+TEST_F(TestCommissioningProxyCluster, TestBgScanStop_ReservedWiFiBandBits)
+{
+    TestServerClusterContext context;
+    BitMask<Feature> features(Feature::kBackgroundScan, Feature::kWiFiNetworkInterface);
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    // Bit 1 (0x02) is reserved in WiFiBandBitmap.
+    auto cmd = MakeBgScanStopRequest(CapabilitiesBitmap::kWiFiPAF,
+                                     chip::MakeOptional(chip::BitMask<WiFiBandBitmap>(static_cast<WiFiBandBitmap>(0x02))));
+    // Impl-defined: the BgScanStop spec mandates no specific status for reserved
+    // WiFiBand bits. This asserts current behavior.
+    auto result = tester.Invoke(cmd);
+    EXPECT_FALSE(result.IsSuccess());
+    EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidCommand));
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// wiFiBands without WI feature is rejected. Impl-defined status: the spec does not
+// mandate a specific status for a WI-conformance field sent without WI; this asserts
+// current behavior (INVALID_COMMAND).
+TEST_F(TestCommissioningProxyCluster, TestBgScanStop_WiFiBandWithoutWIFeature)
+{
+    TestServerClusterContext context;
+    BitMask<Feature> features(Feature::kBackgroundScan); // no kWiFiNetworkInterface
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    // A band-only stop (transport=0 + wiFiBands) without the WI feature SHALL be
+    // rejected with InvalidCommand by the wiFiBands-requires-WI guard.
+    auto cmd    = MakeBgScanStopRequest(static_cast<CapabilitiesBitmap>(0),
+                                        chip::MakeOptional(chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k2g4)));
+    auto result = tester.Invoke(cmd);
+    EXPECT_FALSE(result.IsSuccess());
+    EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidCommand));
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// transport=0 with valid wiFiBands and WI feature — SHALL succeed
+// (a band-only stop is valid per the ProxyBackGroundScanStopRequest behaviour).
+TEST_F(TestCommissioningProxyCluster, TestBgScanStop_BandOnlyStop_Valid)
+{
+    TestServerClusterContext context;
+    BitMask<Feature> features(Feature::kBackgroundScan, Feature::kWiFiNetworkInterface);
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    auto cmd = MakeBgScanStopRequest(static_cast<CapabilitiesBitmap>(0),
+                                     chip::MakeOptional(chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k2g4)));
+    EXPECT_TRUE(tester.Invoke(cmd).IsSuccess());
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// Valid kWiFiPAF stop with k5g wiFiBands — SHALL succeed.
+TEST_F(TestCommissioningProxyCluster, TestBgScanStop_ValidWiFiPAF_5gBand)
+{
+    TestServerClusterContext context;
+    BitMask<Feature> features(Feature::kBackgroundScan, Feature::kWiFiNetworkInterface);
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kWiFiPAF);
+    auto cmd =
+        MakeBgScanStopRequest(CapabilitiesBitmap::kWiFiPAF, chip::MakeOptional(chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k5g)));
+    EXPECT_TRUE(tester.Invoke(cmd).IsSuccess());
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// Per the ProxyBackGroundScanStopRequest Effect on Receipt: if the NodeId and
+// FabricId of the client do not match those used in a previous
+// ProxyBackGroundScanStartRequest, the proxy SHALL take no action and the command
+// SHALL be rejected with NOT_FOUND.  This is transport-side state; the cluster
+// must propagate the transport's NotFound status.
+TEST_F(TestCommissioningProxyCluster, TestBgScanStop_TransportNotFound_Propagated)
+{
+    TestServerClusterContext context;
+    BitMask<Feature> features(Feature::kBackgroundScan);
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    // No transport has a matching per-fabric background-scan record. The cluster
+    // fans the stop to every registered transport and reports NotFound only when
+    // none matched, so both mocks must report NotFound.
+    mockBle.SetBgScanStopStatus(Protocols::InteractionModel::Status::NotFound);
+    mockPaf.SetBgScanStopStatus(Protocols::InteractionModel::Status::NotFound);
+
+    ClusterTester tester(cluster);
+    auto result = tester.Invoke(MakeBgScanStopRequest(CapabilitiesBitmap::kBle));
+    EXPECT_FALSE(result.IsSuccess());
+    EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::NotFound));
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// Per the ProxyBackGroundScanStopRequest Effect on Receipt: "If valid Transports and
+// WiFiBands are received but were not originally requested, the command SHALL return a
+// status of SUCCESS." The cluster fans the stop to every registered transport and
+// reports SUCCESS when at least one matched, even if another reports NOT_FOUND.
+TEST_F(TestCommissioningProxyCluster, TestBgScanStop_PartialMatch_Success)
+{
+    TestServerClusterContext context;
+    BitMask<Feature> features(Feature::kBackgroundScan);
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    // BLE has no matching per-fabric record (NOT_FOUND); PAF reports SUCCESS.
+    mockBle.SetBgScanStopStatus(Protocols::InteractionModel::Status::NotFound);
+    mockPaf.SetBgScanStopStatus(Protocols::InteractionModel::Status::Success);
+
+    ClusterTester tester(cluster);
+    SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
+    EXPECT_TRUE(tester.Invoke(MakeBgScanStopRequest(CapabilitiesBitmap::kBle)).IsSuccess());
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// =============================================================================
+// Additional coverage: read-only writes, unsupported transports, scan BUSY,
+// ProxyMessage TIMEOUT, and CachedResults content.
+// =============================================================================
+
+// Build a minimal ScanResultStruct for injecting into the ScanCache.
+static Structs::ScanResultStruct::Type MakeScanEntry(uint16_t discriminator, CapabilitiesBitmap transport)
+{
+    Structs::ScanResultStruct::Type e;
+    e.transport     = chip::BitMask<CapabilitiesBitmap>(transport);
+    e.discriminator = discriminator;
+    e.vendorID      = static_cast<chip::VendorId>(0x1234);
+    e.productID     = 0x0001;
+    e.address.SetNull();
+    e.extendedData.SetNull();
+    return e;
+}
+
+// As above, but on a band: only PAFTP results carry one.
+static Structs::ScanResultStruct::Type MakePafScanEntry(uint16_t discriminator, WiFiBandBitmap band)
+{
+    auto e = MakeScanEntry(discriminator, CapabilitiesBitmap::kWiFiPAF);
+    e.wiFiBand.SetValue(chip::BitMask<WiFiBandBitmap>(band));
+    return e;
+}
+
+// Spec § ProxyBackGroundScanStopRequest: clearing is scoped to "the transports and
+// bands on which it has stopped scanning", so a band-scoped clear SHALL leave results
+// from other bands cached. A result with no WiFiBand takes the spec's 2G4 fallback.
+TEST_F(TestCommissioningProxyCluster, TestCachedResults_ClearIsBandSelective)
+{
+    BitMask<Feature> features(Feature::kBackgroundScan, Feature::kWiFiNetworkInterface);
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
+    RegisterMocks(cluster);
+
+    ClusterTester tester(cluster);
+    EXPECT_EQ(cluster.Startup(tester.GetServerClusterContext()), CHIP_NO_ERROR);
+
+    auto readNum = [&]() {
+        uint8_t n = 0xFF;
+        EXPECT_EQ(tester.ReadAttribute(CPAttributes::NumCachedResults::Id, n), CHIP_NO_ERROR);
+        return n;
+    };
+
+    cluster.ScanCache().Report(MakePafScanEntry(1000, WiFiBandBitmap::k2g4));
+    cluster.ScanCache().Report(MakePafScanEntry(2000, WiFiBandBitmap::k5g));
+    cluster.ScanCache().Report(MakeScanEntry(3000, CapabilitiesBitmap::kWiFiPAF)); // no band -> 2G4
+    cluster.ScanCache().Report(MakeScanEntry(4000, CapabilitiesBitmap::kBle));
+    EXPECT_EQ(readNum(), 4u);
+
+    // Stopping 2G4 drops the 2G4 entry and the bandless one that falls back to it; the
+    // 5G entry and the BLE entry are untouched.
+    cluster.ScanCache().ClearTransport(chip::BitMask<CapabilitiesBitmap>(CapabilitiesBitmap::kWiFiPAF),
+                                       chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k2g4));
+    EXPECT_EQ(readNum(), 2u);
+
+    // A whole-transport clear (bands == 0) takes the remaining PAF entry, not the BLE one.
+    cluster.ScanCache().ClearTransport(chip::BitMask<CapabilitiesBitmap>(CapabilitiesBitmap::kWiFiPAF));
+    EXPECT_EQ(readNum(), 1u);
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// A valid transport bit with no registered driver SHALL be rejected with
+// INVALID_TRANSPORT_TYPE (test plan TC-2.4 step 11).  Register only BLE, request PAF.
+// This is the distinction the two rejection statuses draw: a spec-defined transport with
+// no driver is InvalidTransportType, whereas an undefined bit is a malformed field and
+// gets InvalidCommand (see _ReservedTransportBitOnly). The certification tests rely on
+// it to choose a transport bit for their negative steps.
+TEST_F(TestCommissioningProxyCluster, TestProxyConnectRequest_UnsupportedTransport_InvalidTransportType)
+{
+    TestServerClusterContext context;
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
+    cluster.RegisterTransport(mockBle); // only BLE
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
+
+    auto result = tester.Invoke(MakeConnectRequest(CapabilitiesBitmap::kWiFiPAF));
+    EXPECT_FALSE(result.IsSuccess());
+    EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidTransportType));
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// ProxyScanRequest for a valid-but-unregistered transport SHALL be rejected with
+// INVALID_TRANSPORT_TYPE (test plan TC-2.2 step 9).
+TEST_F(TestCommissioningProxyCluster, TestProxyScanRequest_UnsupportedTransport_InvalidTransportType)
+{
+    TestServerClusterContext context;
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
+    cluster.RegisterTransport(mockBle); // only BLE
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
+
+    Commands::ProxyScanRequest::Type command;
+    command.transport = CapabilitiesBitmap::kWiFiPAF;
+    auto result       = tester.Invoke(command);
+    EXPECT_FALSE(result.IsSuccess());
+    EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidTransportType));
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// ProxyBackGroundScanStartRequest for a valid-but-unregistered transport SHALL be
+// rejected with INVALID_TRANSPORT_TYPE (spec + test plan TC-2.3 step 19).
+TEST_F(TestCommissioningProxyCluster, TestBgScanStart_UnsupportedTransport_InvalidTransportType)
+{
+    TestServerClusterContext context;
+    BitMask<Feature> features(Feature::kBackgroundScan);
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
+    cluster.RegisterTransport(mockBle); // only BLE
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
+
+    auto result = tester.Invoke(MakeBgScanStartRequest(CapabilitiesBitmap::kWiFiPAF));
+    EXPECT_FALSE(result.IsSuccess());
+    EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidTransportType));
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// Every command carrying a Transport field validates it separately, so the four checks can
+// drift apart — ProxyConnectRequest once answered a reserved bit with InvalidTransportType
+// while the three scan commands answered InvalidCommand, leaving the same malformed
+// request with two different meanings depending on which command carried it. A reserved
+// bit is a malformed field on all four; InvalidTransportType is reserved for a
+// spec-defined transport the proxy has no driver for (see
+// _UnsupportedTransport_InvalidTransportType). Assert the four agree.
+TEST_F(TestCommissioningProxyCluster, TestReservedTransportBitRejectedAlikeByEveryCommand)
+{
+    TestServerClusterContext context;
+    BitMask<Feature> features(Feature::kBackgroundScan, Feature::kWiFiNetworkInterface);
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+
+    // 0x01 is outside the spec-defined set (kBle=0x02, kWiFiPAF=0x08, kNtl=0x10). Sent
+    // alone so the single-transport rule ProxyConnectRequest applies cannot be what
+    // rejects it, and every command reaches its own reserved-bit check.
+    constexpr auto kReserved = static_cast<CapabilitiesBitmap>(0x01);
+    const auto expected      = ClusterStatusCode(Protocols::InteractionModel::Status::InvalidCommand);
+
+    EXPECT_EQ(tester.Invoke(MakeConnectRequest(kReserved)).GetStatusCode(), expected) << "ProxyConnectRequest";
+
+    Commands::ProxyScanRequest::Type scan;
+    scan.transport = kReserved;
+    EXPECT_EQ(tester.Invoke(Commands::ProxyScanRequest::Id, scan).GetStatusCode(), expected) << "ProxyScanRequest";
+
+    EXPECT_EQ(tester.Invoke(MakeBgScanStartRequest(kReserved)).GetStatusCode(), expected) << "ProxyBackGroundScanStartRequest";
+
+    EXPECT_EQ(tester.Invoke(MakeBgScanStopRequest(kReserved)).GetStatusCode(), expected) << "ProxyBackGroundScanStopRequest";
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// Spec: a ProxyScanRequest received while one is in progress MAY be answered with
+// BUSY. This implementation chose the BUSY mechanism; verify it.
+TEST_F(TestCommissioningProxyCluster, TestProxyScanRequest_ConcurrentBusy)
+{
+    TestServerClusterContext context;
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
+
+    // Leave the first scan in-flight (no synchronous contribution) so the aggregator
+    // stays busy for the second request.
+    mockBle.SetAutoContribute(false);
+    Commands::ProxyScanRequest::Type command;
+    command.transport           = CapabilitiesBitmap::kBle;
+    [[maybe_unused]] auto first = tester.Invoke(command); // stays pending
+
+    auto second = tester.Invoke(command);
+    EXPECT_FALSE(second.IsSuccess());
+    EXPECT_EQ(second.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::Busy));
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// The scan watchdog is the only thing that can end an aggregation whose sub-scan never
+// reports. If it cannot be armed the command SHALL be rejected rather than started,
+// because an aggregation with no watchdog would never complete — and the rejection SHALL
+// roll the aggregation back, or every later scan would be answered BUSY for the lifetime
+// of the process.
+TEST_F(TestCommissioningProxyCluster, TestProxyScanRequest_WatchdogArmFailureRejectsAndRollsBack)
+{
+    TestServerClusterContext context;
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
+
+    mockTimer.FailNextStart();
+
+    Commands::ProxyScanRequest::Type command;
+    command.transport = CapabilitiesBitmap::kBle;
+
+    auto result = tester.Invoke(command);
+    EXPECT_FALSE(result.IsSuccess());
+    EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::Failure));
+
+    // The aggregation was rolled back, so no watchdog is left armed...
+    EXPECT_EQ(mockTimer.ActiveCount(), 0u);
+
+    // ... and the next request arms its own watchdog and is accepted, not BUSY.
+    EXPECT_TRUE(tester.Invoke(command).IsSuccess());
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// A driver whose transport connection drops mid-exchange reports it through the session
+// manager rather than leaving the commissioner waiting for the response timer. The
+// pending request SHALL be answered with the reported status and its timer released.
+TEST_F(TestCommissioningProxyCluster, TestProxyMessageRequest_TransportFailureAnswersPending)
+{
+    TestServerClusterContext context;
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    uint16_t sid = OpenSession(tester);
+
+    mockBle.SetAutoRespond(false);
+
+    static const uint8_t kMsg[] = { 0xAB };
+    Commands::ProxyMessageRequest::Type cmd;
+    cmd.sessionID       = sid;
+    cmd.responseTimeout = 5;
+    cmd.message.SetNonNull(chip::ByteSpan(kMsg, sizeof(kMsg)));
+
+    [[maybe_unused]] auto pending = tester.Invoke(cmd);
+    EXPECT_FALSE(tester.GetCommandHandler().HasStatus());
+    EXPECT_EQ(mockTimer.ActiveCount(), 1u); // the response timer is armed
+
+    // The BTP/PAFTP link drops: the driver reports the failure for this session.
+    mockBle.FailPendingMessage(sid, Protocols::InteractionModel::Status::Failure);
+
+    ASSERT_TRUE(tester.GetCommandHandler().HasStatus());
+    EXPECT_EQ(tester.GetCommandHandler().GetLastStatus().status, ClusterStatusCode(Protocols::InteractionModel::Status::Failure));
+    EXPECT_EQ(mockTimer.ActiveCount(), 0u); // ... and the response timer went with it
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// Spec § ProxyMessageResponse: "If the ResponseTimeout from the ProxyMessageRequest
+// expires the TIMEOUT status SHALL be returned."
+TEST_F(TestCommissioningProxyCluster, TestProxyMessageRequest_ResponseTimeout)
+{
+    TestServerClusterContext context;
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    uint16_t sid = OpenSession(tester);
+
+    // The commissionee never replies, so the request stays pending and only the session's
+    // response timer can resolve it.
+    mockBle.SetAutoRespond(false);
+
+    static const uint8_t kMsg[] = { 0xAB };
+    Commands::ProxyMessageRequest::Type cmd;
+    cmd.sessionID       = sid;
+    cmd.responseTimeout = 5;
+    cmd.message.SetNonNull(chip::ByteSpan(kMsg, sizeof(kMsg)));
+
+    // No answer yet: the cluster holds the handle open waiting for the commissionee.
+    // (Invoke() reports its own error for a command that answers asynchronously, so the
+    // handler is what says whether the commissioner has been answered.)
+    [[maybe_unused]] auto pending = tester.Invoke(cmd);
+    EXPECT_FALSE(tester.GetCommandHandler().HasStatus());
+    EXPECT_FALSE(tester.GetCommandHandler().HasResponse());
+
+    // Advancing past ResponseTimeout fires the timer, which answers with TIMEOUT.
+    mockTimer.AdvanceClock(System::Clock::Seconds16(5));
+    ASSERT_TRUE(tester.GetCommandHandler().HasStatus());
+    EXPECT_EQ(tester.GetCommandHandler().GetLastStatus().status, ClusterStatusCode(Protocols::InteractionModel::Status::Timeout));
+
+    // The expired request no longer holds the session, so the next one is accepted
+    // rather than rejected with BUSY.
+    mockBle.SetAutoRespond(true);
+    EXPECT_TRUE(tester.Invoke(cmd).IsSuccess());
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// A ProxyBackGroundScanStartRequest naming several transports must not report failure
+// while leaving one of them scanning: the commissioner would believe nothing started.
+// Either every requested transport starts, or the command rolls back and fails.
+TEST_F(TestCommissioningProxyCluster, TestProxyBackgroundScanStart_PartialFailureRollsBack)
+{
+    TestServerClusterContext context;
+    BitMask<Feature> features(Feature::kBackgroundScan, Feature::kWiFiNetworkInterface);
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
+    SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kWiFiPAF);
+
+    // BLE accepts the background scan; Wi-Fi PAF rejects it.
+    mockPaf.SetBgScanStartStatus(Protocols::InteractionModel::Status::Failure);
+
+    Commands::ProxyBackGroundScanStartRequest::Type cmd;
+    cmd.transport = chip::BitMask<CapabilitiesBitmap>(CapabilitiesBitmap::kBle, CapabilitiesBitmap::kWiFiPAF);
+    cmd.timeout   = 30;
+
+    EXPECT_FALSE(tester.Invoke(cmd).IsSuccess());
+
+    // BLE's scan must have been stopped again, so "failed" means nothing is running.
+    EXPECT_FALSE(mockBle.BgScanRunning());
+    EXPECT_EQ(mockBle.BgScanStopCount(), 1u);
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// A sub-scan whose completion callback never fires must not block the aggregator
+// forever: the watchdog ends the aggregation so later scans are still accepted.
+TEST_F(TestCommissioningProxyCluster, TestProxyScanRequest_WatchdogEndsStalledAggregation)
+{
+    TestServerClusterContext context;
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
+
+    // The sub-scan starts but never reports, so only the watchdog can finish this.
+    mockBle.SetAutoContribute(false);
+    Commands::ProxyScanRequest::Type command;
+    command.transport             = CapabilitiesBitmap::kBle;
+    [[maybe_unused]] auto stalled = tester.Invoke(command);
+
+    // A second request while the first is still aggregating is rejected.
+    EXPECT_EQ(tester.Invoke(command).GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::Busy));
+
+    // ScanMaxTime defaults to 10s and the watchdog adds a 5s margin.
+    mockTimer.AdvanceClock(System::Clock::Seconds16(16));
+
+    // The aggregation has been closed out, so a new scan is accepted again.
+    mockBle.SetAutoContribute(true);
+    EXPECT_TRUE(tester.Invoke(command).IsSuccess());
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// The response timer is what eventually answers a ProxyMessageRequest the commissionee
+// never replies to. If it cannot be armed the command SHALL be rejected, so the
+// commissioner gets an answer instead of an exchange that is never resolved.
+TEST_F(TestCommissioningProxyCluster, TestProxyMessageRequest_ResponseTimerArmFailureRejects)
+{
+    TestServerClusterContext context;
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    uint16_t sid = OpenSession(tester);
+
+    // The commissionee would not reply on its own, so only the timer could resolve this.
+    mockBle.SetAutoRespond(false);
+
+    static const uint8_t kMsg[] = { 0xAB };
+    Commands::ProxyMessageRequest::Type cmd;
+    cmd.sessionID       = sid;
+    cmd.responseTimeout = 5;
+    cmd.message.SetNonNull(chip::ByteSpan(kMsg, sizeof(kMsg)));
+
+    mockTimer.FailNextStart();
+
+    auto result = tester.Invoke(cmd);
+    EXPECT_FALSE(result.IsSuccess());
+    EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::Failure));
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// A removed fabric SHALL leave nothing behind: its sessions are torn down through the
+// owning transport, and every driver is told to drop its background scans. FabricIndex
+// values are reused, so residue would be inherited by the next fabric to take the index.
+TEST_F(TestCommissioningProxyCluster, TestOnFabricRemoved_DropsSessionsAndDriverState)
+{
+    TestServerClusterContext context;
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    tester.SetFabricIndex(1);
+    uint16_t sid = OpenSession(tester);
+    EXPECT_TRUE(cluster.Sessions().FindSession(sid).has_value());
+
+    // No FabricTable in unit tests, so drive the delegate callback directly.
+    cluster.OnFabricRemoved(chip::Server::GetInstance().GetFabricTable(), 1);
+
+    EXPECT_FALSE(cluster.Sessions().FindSession(sid).has_value());
+    EXPECT_TRUE(cluster.Sessions().IsEmpty());
+    EXPECT_EQ(mockBle.FabricRemovedCount(), 1u);
+    EXPECT_EQ(mockPaf.FabricRemovedCount(), 1u);
+    EXPECT_EQ(mockBle.LastRemovedFabric(), 1);
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// Another fabric's session SHALL survive.
+TEST_F(TestCommissioningProxyCluster, TestOnFabricRemoved_LeavesOtherFabricsAlone)
+{
+    SKIP_IF_MAX_SESSIONS_BELOW(2);
+
+    TestServerClusterContext context;
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    tester.SetFabricIndex(1);
+    uint16_t sid1 = OpenSession(tester);
+    tester.SetFabricIndex(2);
+    uint16_t sid2 = OpenSession(tester);
+
+    cluster.OnFabricRemoved(chip::Server::GetInstance().GetFabricTable(), 1);
+
+    EXPECT_FALSE(cluster.Sessions().FindSession(sid1).has_value());
+    EXPECT_TRUE(cluster.Sessions().FindSession(sid2).has_value());
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// CachedResults / NumCachedResults reflect the ScanCache: null/0 when empty, unique
+// per discriminator/VID/PID/transport (spec), and cleared by ClearTransport. Change
+// reporting for both attributes is the cluster's responsibility.
+TEST_F(TestCommissioningProxyCluster, TestCachedResults_ReportDedupAndClear)
+{
+    BitMask<Feature> bgs(Feature::kBackgroundScan);
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(bgs), mockTimer);
+    RegisterMocks(cluster);
+
+    ClusterTester tester(cluster);
+    EXPECT_EQ(cluster.Startup(tester.GetServerClusterContext()), CHIP_NO_ERROR);
+
+    auto readNum = [&]() {
+        uint8_t n = 0xFF;
+        EXPECT_EQ(tester.ReadAttribute(CPAttributes::NumCachedResults::Id, n), CHIP_NO_ERROR);
+        return n;
+    };
+
+    // Empty cache: NumCachedResults 0, CachedResults null.
+    EXPECT_EQ(readNum(), 0u);
+    {
+        Attributes::CachedResults::TypeInfo::DecodableType list;
+        ASSERT_EQ(tester.ReadAttribute(CPAttributes::CachedResults::Id, list), CHIP_NO_ERROR);
+        EXPECT_TRUE(list.IsNull());
+    }
+
+    // Report device A → count 1; CachedResults and NumCachedResults marked dirty.
+    cluster.ScanCache().Report(MakeScanEntry(1000, CapabilitiesBitmap::kBle));
+    EXPECT_EQ(readNum(), 1u);
+    EXPECT_TRUE(tester.IsAttributeDirty(CPAttributes::CachedResults::Id));
+    EXPECT_TRUE(tester.IsAttributeDirty(CPAttributes::NumCachedResults::Id));
+
+    // Report device B (different discriminator) → count 2.
+    cluster.ScanCache().Report(MakeScanEntry(2000, CapabilitiesBitmap::kBle));
+    EXPECT_EQ(readNum(), 2u);
+
+    // Re-report device A (same discriminator/VID/PID/transport) → dedup, count unchanged.
+    cluster.ScanCache().Report(MakeScanEntry(1000, CapabilitiesBitmap::kBle));
+    EXPECT_EQ(readNum(), 2u);
+
+    // CachedResults is now a non-null list with 2 entries.
+    {
+        Attributes::CachedResults::TypeInfo::DecodableType list;
+        ASSERT_EQ(tester.ReadAttribute(CPAttributes::CachedResults::Id, list), CHIP_NO_ERROR);
+        ASSERT_FALSE(list.IsNull());
+        size_t count = 0;
+        auto it      = list.Value().begin();
+        while (it.Next())
+        {
+            ++count;
+        }
+        EXPECT_EQ(it.GetStatus(), CHIP_NO_ERROR);
+        EXPECT_EQ(count, 2u);
+    }
+
+    // Clearing the BLE transport removes all its entries → count 0.
+    cluster.ScanCache().ClearTransport(chip::BitMask<CapabilitiesBitmap>(CapabilitiesBitmap::kBle));
+    EXPECT_EQ(readNum(), 0u);
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// Spec § ProxyBackGroundScanStartRequest: "The proxy SHALL retain each result for
+// CacheTimeout ... and SHALL remove the result upon expiry."
+TEST_F(TestCommissioningProxyCluster, TestCachedResults_EntryExpiresAfterCacheTimeout)
+{
+    BitMask<Feature> bgs(Feature::kBackgroundScan);
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(bgs), mockTimer);
+    RegisterMocks(cluster);
+
+    ClusterTester tester(cluster);
+    EXPECT_EQ(cluster.Startup(tester.GetServerClusterContext()), CHIP_NO_ERROR);
+
+    auto readNum = [&]() {
+        uint8_t n = 0xFF;
+        EXPECT_EQ(tester.ReadAttribute(CPAttributes::NumCachedResults::Id, n), CHIP_NO_ERROR);
+        return n;
+    };
+
+    // CacheTimeout defaults to 120s.
+    cluster.ScanCache().Report(MakeScanEntry(1000, CapabilitiesBitmap::kBle));
+    EXPECT_EQ(readNum(), 1u);
+
+    // Still inside the TTL: the sweep runs but keeps the entry.
+    mockTimer.AdvanceClock(System::Clock::Seconds16(119));
+    EXPECT_EQ(readNum(), 1u);
+
+    // Past the TTL: the entry is dropped and both attributes are reported.
+    tester.GetDirtyList().clear();
+    mockTimer.AdvanceClock(System::Clock::Seconds16(2));
+    EXPECT_EQ(readNum(), 0u);
+    EXPECT_TRUE(tester.IsAttributeDirty(CPAttributes::CachedResults::Id));
+    EXPECT_TRUE(tester.IsAttributeDirty(CPAttributes::NumCachedResults::Id));
+
+    // Nothing left to sweep, so the cache stops re-arming its timer.
+    EXPECT_EQ(mockTimer.ActiveCount(), 0u);
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// Spec § ProxyBackGroundScanStartRequest: "for each discovered device, the proxy SHALL
+// reset the timer associated to this device on rediscovery of the same device".
+TEST_F(TestCommissioningProxyCluster, TestCachedResults_RediscoveryResetsTtl)
+{
+    BitMask<Feature> bgs(Feature::kBackgroundScan);
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(bgs), mockTimer);
+    RegisterMocks(cluster);
+
+    ClusterTester tester(cluster);
+    EXPECT_EQ(cluster.Startup(tester.GetServerClusterContext()), CHIP_NO_ERROR);
+
+    auto readNum = [&]() {
+        uint8_t n = 0xFF;
+        EXPECT_EQ(tester.ReadAttribute(CPAttributes::NumCachedResults::Id, n), CHIP_NO_ERROR);
+        return n;
+    };
+
+    cluster.ScanCache().Report(MakeScanEntry(1000, CapabilitiesBitmap::kBle));
+
+    // Rediscovered at t=100s, which pushes expiry out to t=220s.
+    mockTimer.AdvanceClock(System::Clock::Seconds16(100));
+    cluster.ScanCache().Report(MakeScanEntry(1000, CapabilitiesBitmap::kBle));
+
+    // t=130s is past the ORIGINAL 120s expiry; the refreshed entry SHALL survive.
+    mockTimer.AdvanceClock(System::Clock::Seconds16(30));
+    EXPECT_EQ(readNum(), 1u);
+
+    // t=221s is past the refreshed expiry.
+    mockTimer.AdvanceClock(System::Clock::Seconds16(91));
+    EXPECT_EQ(readNum(), 0u);
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// Entries with different TTLs expire independently, and the sweep keeps re-arming
+// while any entry remains.
+TEST_F(TestCommissioningProxyCluster, TestCachedResults_SweepExpiresOnlyDueEntries)
+{
+    BitMask<Feature> bgs(Feature::kBackgroundScan);
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(bgs), mockTimer);
+    RegisterMocks(cluster);
+
+    ClusterTester tester(cluster);
+    EXPECT_EQ(cluster.Startup(tester.GetServerClusterContext()), CHIP_NO_ERROR);
+
+    auto readNum = [&]() {
+        uint8_t n = 0xFF;
+        EXPECT_EQ(tester.ReadAttribute(CPAttributes::NumCachedResults::Id, n), CHIP_NO_ERROR);
+        return n;
+    };
+
+    cluster.ScanCache().Report(MakeScanEntry(1000, CapabilitiesBitmap::kBle)); // expires t=120
+    mockTimer.AdvanceClock(System::Clock::Seconds16(60));
+    cluster.ScanCache().Report(MakeScanEntry(2000, CapabilitiesBitmap::kBle)); // expires t=180
+    EXPECT_EQ(readNum(), 2u);
+
+    // t=121: only the first entry is due; the sweep stays armed for the second.
+    mockTimer.AdvanceClock(System::Clock::Seconds16(61));
+    EXPECT_EQ(readNum(), 1u);
+    EXPECT_EQ(mockTimer.ActiveCount(), 1u);
+
+    mockTimer.AdvanceClock(System::Clock::Seconds16(60));
+    EXPECT_EQ(readNum(), 0u);
+    EXPECT_EQ(mockTimer.ActiveCount(), 0u);
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// A cache destroyed while its TTL sweep is armed must cancel the timer on the way out:
+// the TimerDelegate holds a pointer to the cache as its TimerContext, and would
+// dereference freed memory when the sweep came due.
+TEST_F(TestCommissioningProxyCluster, TestScanCache_DestructorCancelsSweepTimer)
+{
+    class CacheObserver : public ScanCacheObserver
+    {
+    public:
+        void MarkCachedResultsDirty() override {}
+        uint16_t GetCacheTimeout() const override { return 60; }
+        uint8_t GetMaxCachedResults() const override { return 10; }
+    };
+
+    CacheObserver observer;
+    {
+        CommissioningProxyScanCache cache(observer, mockTimer);
+        cache.Report(MakeScanEntry(1000, CapabilitiesBitmap::kBle));
+        EXPECT_EQ(mockTimer.ActiveCount(), 1u); // the sweep is armed
+    }
+
+    // Shutdown() was never called, so only the destructor can have cancelled it.
+    EXPECT_EQ(mockTimer.ActiveCount(), 0u);
+}
+
+// =============================================================================
+// Additional coverage: fabric isolation on established sessions, session
+// lifecycle, ProxyMessage edge paths, scan sub-scan handling, GeneratedCommands.
+// =============================================================================
+
+// Spec: the proxy associates a session with the invoking fabric, and a disconnect
+// whose fabric does not match SHALL be rejected with NOT_FOUND. Fabric 1 opens a
+// session; fabric 2 cannot disconnect it, but fabric 1 can.
+TEST_F(TestCommissioningProxyCluster, TestProxyDisconnectRequest_WrongFabricEstablishedSession_NotFound)
+{
+    TestServerClusterContext context;
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
+
+    tester.SetFabricIndex(1);
+    uint16_t sid = OpenSession(tester);
+
+    Commands::ProxyDisconnectRequest::Type cmd;
+    cmd.sessionID.SetNonNull(sid);
+
+    // Fabric 2 attempts to disconnect fabric 1's session → NOT_FOUND.
+    tester.SetFabricIndex(2);
+    auto result = tester.Invoke(cmd);
+    EXPECT_FALSE(result.IsSuccess());
+    EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::NotFound));
+
+    // The owning fabric can still disconnect it (session survived the foreign attempt).
+    tester.SetFabricIndex(1);
+    EXPECT_TRUE(tester.Invoke(cmd).IsSuccess());
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// Spec: a ProxyMessageRequest whose fabric does not match the session's fabric SHALL
+// be rejected with NOT_FOUND.
+TEST_F(TestCommissioningProxyCluster, TestProxyMessageRequest_WrongFabricEstablishedSession_NotFound)
+{
+    TestServerClusterContext context;
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
+
+    tester.SetFabricIndex(1);
+    uint16_t sid = OpenSession(tester);
+
+    tester.SetFabricIndex(2);
+    static const uint8_t kMsg[] = { 0x01 };
+    Commands::ProxyMessageRequest::Type cmd;
+    cmd.sessionID       = sid;
+    cmd.responseTimeout = 5;
+    cmd.message.SetNonNull(chip::ByteSpan(kMsg, sizeof(kMsg)));
+
+    auto result = tester.Invoke(cmd);
+    EXPECT_FALSE(result.IsSuccess());
+    EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::NotFound));
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// Spec: "The SessionId allows multiple commissioning sessions to be run in parallel."
+// With MaxSessions >= 2, two connects SHALL each get a distinct, non-zero SessionId.
+TEST_F(TestCommissioningProxyCluster, TestProxyConnectRequest_MultipleSessionsHaveDistinctSessionIds)
+{
+    SKIP_IF_MAX_SESSIONS_BELOW(2);
+
+    TestServerClusterContext context;
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
+
+    auto first = tester.Invoke(MakeConnectRequest(CapabilitiesBitmap::kBle));
+    ASSERT_TRUE(first.IsSuccess());
+    ASSERT_TRUE(first.response.has_value());
+    auto second = tester.Invoke(MakeConnectRequest(CapabilitiesBitmap::kBle));
+    ASSERT_TRUE(second.IsSuccess());
+    ASSERT_TRUE(second.response.has_value());
+
+    if (first.response.has_value() && second.response.has_value())
+    {
+        EXPECT_NE(first.response->sessionID, 0u);
+        EXPECT_NE(second.response->sessionID, 0u);
+        EXPECT_NE(first.response->sessionID, second.response->sessionID);
+    }
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// A ProxyDisconnectRequest SHALL remove the session: a subsequent ProxyMessageRequest
+// referencing the same SessionId SHALL be rejected with NOT_FOUND.
+TEST_F(TestCommissioningProxyCluster, TestProxyDisconnectRequest_RemovesSession)
+{
+    TestServerClusterContext context;
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
+
+    uint16_t sid = OpenSession(tester);
+
+    Commands::ProxyDisconnectRequest::Type dc;
+    dc.sessionID.SetNonNull(sid);
+    EXPECT_TRUE(tester.Invoke(dc).IsSuccess());
+
+    static const uint8_t kMsg[] = { 0x01 };
+    Commands::ProxyMessageRequest::Type msg;
+    msg.sessionID       = sid;
+    msg.responseTimeout = 5;
+    msg.message.SetNonNull(chip::ByteSpan(kMsg, sizeof(kMsg)));
+    auto result = tester.Invoke(msg);
+    EXPECT_FALSE(result.IsSuccess());
+    EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::NotFound));
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// Spec § ProxyMessageRequest ResponseTimeout: "A value of zero indicates no response
+// is expected and the proxy should send ProxyMessageResponse immediately indicating
+// success." The response message SHALL be null.
+TEST_F(TestCommissioningProxyCluster, TestProxyMessageRequest_ResponseTimeoutZero_ImmediateSuccess)
+{
+    TestServerClusterContext context;
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    uint16_t sid = OpenSession(tester);
+
+    static const uint8_t kMsg[] = { 0x01, 0x02 };
+    Commands::ProxyMessageRequest::Type cmd;
+    cmd.sessionID       = sid;
+    cmd.responseTimeout = 0;
+    cmd.message.SetNonNull(chip::ByteSpan(kMsg, sizeof(kMsg)));
+
+    auto result = tester.Invoke(cmd);
+    ASSERT_TRUE(result.IsSuccess());
+    ASSERT_TRUE(result.response.has_value());
+    if (result.response.has_value())
+    {
+        EXPECT_EQ(result.response->sessionID, sid);
+        EXPECT_TRUE(result.response->message.IsNull());
+    }
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// If the transport SendMessage fails, the command SHALL fail and the pending state
+// SHALL be rolled back (the session is not left BUSY for the next request).
+TEST_F(TestCommissioningProxyCluster, TestProxyMessageRequest_SendMessageFailure_RollsBackPending)
+{
+    TestServerClusterContext context;
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    uint16_t sid = OpenSession(tester);
+
+    static const uint8_t kMsg[] = { 0xAA };
+    Commands::ProxyMessageRequest::Type cmd;
+    cmd.sessionID       = sid;
+    cmd.responseTimeout = 5;
+    cmd.message.SetNonNull(chip::ByteSpan(kMsg, sizeof(kMsg)));
+
+    // First request: the transport send fails → FAILURE.
+    mockBle.SetSendMessageError(CHIP_ERROR_INTERNAL);
+    auto first = tester.Invoke(cmd);
+    EXPECT_FALSE(first.IsSuccess());
+    EXPECT_EQ(first.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::Failure));
+
+    // The pending state was rolled back: a follow-up request is not rejected as BUSY.
+    mockBle.SetSendMessageError(CHIP_NO_ERROR);
+    EXPECT_TRUE(tester.Invoke(cmd).IsSuccess());
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// If no requested sub-scan can be started, the ProxyScanRequest SHALL fail rather
+// than hang waiting for a contribution that never comes.
+TEST_F(TestCommissioningProxyCluster, TestProxyScanRequest_AllSubScansFailToStart_Error)
+{
+    TestServerClusterContext context;
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
+
+    mockBle.SetScanStatus(Protocols::InteractionModel::Status::Failure);
+    Commands::ProxyScanRequest::Type command;
+    command.transport = CapabilitiesBitmap::kBle;
+    EXPECT_FALSE(tester.Invoke(command).IsSuccess());
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// If some (but not all) requested sub-scans start, the combined ProxyScanResponse
+// SHALL carry the results of the transports that did start.
+TEST_F(TestCommissioningProxyCluster, TestProxyScanRequest_PartialStart_ReturnsStartedResults)
+{
+    TestServerClusterContext context;
+    BitMask<Feature> features(Feature::kWiFiNetworkInterface);
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
+    RegisterMocks(cluster);
+    cluster.SetSupportedWiFiBands(chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k2g4));
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ClusterTester tester(cluster);
+    SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
+    SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kWiFiPAF);
+
+    // PAF fails to start; BLE succeeds and contributes 2 results.
+    mockPaf.SetScanStatus(Protocols::InteractionModel::Status::Failure);
+    Commands::ProxyScanRequest::Type command;
+    command.transport.Set(CapabilitiesBitmap::kBle);
+    command.transport.Set(CapabilitiesBitmap::kWiFiPAF);
+
+    auto result = tester.Invoke(command);
+    ASSERT_TRUE(result.IsSuccess());
+    ASSERT_TRUE(result.response.has_value());
+    if (result.response.has_value())
+    {
+        EXPECT_EQ(result.response->numberOfResults, 2u);
+    }
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+// GeneratedCommandList SHALL advertise the three response commands the server
+// generates (regression guard for the GeneratedCommands override).
+TEST_F(TestCommissioningProxyCluster, TestGeneratedCommandsAdvertisesResponses)
+{
+    TestServerClusterContext context;
+    CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
+    RegisterMocks(cluster);
+    EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
+
+    ReadOnlyBufferBuilder<CommandId> generated;
+    EXPECT_EQ(cluster.GeneratedCommands(ConcreteClusterPath(kTestEndpointId, CommissioningProxy::Id), generated), CHIP_NO_ERROR);
+    auto buffer = generated.TakeBuffer();
+
+    bool hasConnect = false, hasScan = false, hasMessage = false;
+    for (const CommandId id : buffer)
+    {
+        hasConnect |= (id == Commands::ProxyConnectResponse::Id);
+        hasScan |= (id == Commands::ProxyScanResponse::Id);
+        hasMessage |= (id == Commands::ProxyMessageResponse::Id);
+    }
+    EXPECT_TRUE(hasConnect);
+    EXPECT_TRUE(hasScan);
+    EXPECT_TRUE(hasMessage);
+    EXPECT_EQ(buffer.size(), 3u);
+
+    cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
+}
+
+} // namespace
diff --git a/src/app/server-cluster/testing/ClusterTester.h b/src/app/server-cluster/testing/ClusterTester.h
index cd2101f..5b8b0d1 100644
--- a/src/app/server-cluster/testing/ClusterTester.h
+++ b/src/app/server-cluster/testing/ClusterTester.h
@@ -112,6 +112,13 @@
     TestServerClusterContext & GetTestContext() { return mTestServerClusterContext; }
     app::ServerClusterContext & GetServerClusterContext() { return mTestServerClusterContext.Get(); }
 
+    // The handler every Invoke() runs against. Exposed so a test can inspect a response
+    // or status the cluster adds after Invoke() has already returned — an async command
+    // that retains the handle and answers from a timer or a completion callback. Note
+    // that Invoke() clears the recorded responses and statuses, so read this before
+    // invoking anything else.
+    chip::Testing::MockCommandHandler & GetCommandHandler() { return mHandler; }
+
     // Read attribute into `out` parameter.
     // The `out` parameter must be of the correct type for the attribute being read.
     // Use `app::Clusters::<ClusterName>::Attributes::<AttributeName>::TypeInfo::DecodableType` for the `out` parameter to be spec
diff --git a/src/app/zap_cluster_list.json b/src/app/zap_cluster_list.json
index 30671f3..fb39bbd 100644
--- a/src/app/zap_cluster_list.json
+++ b/src/app/zap_cluster_list.json
@@ -216,7 +216,7 @@
         "COLOR_CONTROL_CLUSTER": ["color-control-server"],
         "COMMISSIONER_CONTROL_CLUSTER": ["commissioner-control-server"],
         "COMMISSIONING_CLUSTER": [],
-        "COMMISSIONING_PROXY_CLUSTER": [],
+        "COMMISSIONING_PROXY_CLUSTER": ["commissioning-proxy-server"],
         "COMMODITY_METERING_CLUSTER": ["commodity-metering-server"],
         "COMMODITY_PRICE_CLUSTER": ["commodity-price-server"],
         "COMMODITY_TARIFF_CLUSTER": ["commodity-tariff-server"],
diff --git a/src/lib/core/CHIPConfig.h b/src/lib/core/CHIPConfig.h
index 4c0c071..3af0e60 100644
--- a/src/lib/core/CHIPConfig.h
+++ b/src/lib/core/CHIPConfig.h
@@ -2086,6 +2086,65 @@
 #endif // CHIP_CONFIG_MAX_NUM_PUSH_TRANSPORTS
 
 /**
+ * @def CHIP_CONFIG_COMMISSIONING_PROXY_MAX_SESSIONS
+ *
+ * @brief The value of the CommissioningProxy cluster's MaxSessions attribute, and the
+ *        number of concurrent proxy sessions the cluster reserves storage for. The
+ *        attribute is Fixed quality, so this is the device's advertised value.
+ *
+ *        A value of 1 means the proxy can commission only one device at a time; supporting
+ *        multiple concurrent sessions is optional. The default is that mandatory minimum
+ *        because every additional session costs fixed storage on every platform that
+ *        compiles the cluster: one SessionSlot plus one PendingMessage pool entry, used or
+ *        not. Products that want concurrent commissioning override this.
+ */
+#ifndef CHIP_CONFIG_COMMISSIONING_PROXY_MAX_SESSIONS
+#define CHIP_CONFIG_COMMISSIONING_PROXY_MAX_SESSIONS 1
+#endif // CHIP_CONFIG_COMMISSIONING_PROXY_MAX_SESSIONS
+
+// Zero would declare a zero-length array, which compiles silently and leaves the cluster
+// with no usable session slots. The upper bound is the width of the MaxSessions attribute.
+#if CHIP_CONFIG_COMMISSIONING_PROXY_MAX_SESSIONS < 1 || CHIP_CONFIG_COMMISSIONING_PROXY_MAX_SESSIONS > 255
+#error "CHIP_CONFIG_COMMISSIONING_PROXY_MAX_SESSIONS is not allowed to be a number less than 1 or greater than 255"
+#endif
+
+/**
+ * @def CHIP_CONFIG_COMMISSIONING_PROXY_MAX_CACHED_RESULTS
+ *
+ * @brief The value of the CommissioningProxy cluster's MaxCachedResults attribute, and
+ *        the number of background-scan results the cache reserves storage for. Each
+ *        entry holds the spec's maximum Address (100) and ExtendedData (128) inline.
+ */
+#ifndef CHIP_CONFIG_COMMISSIONING_PROXY_MAX_CACHED_RESULTS
+#define CHIP_CONFIG_COMMISSIONING_PROXY_MAX_CACHED_RESULTS 10
+#endif // CHIP_CONFIG_COMMISSIONING_PROXY_MAX_CACHED_RESULTS
+
+// Zero would declare a zero-length array, which compiles silently and leaves the cache
+// with no usable entries. The upper bound is the width of the MaxCachedResults attribute.
+#if CHIP_CONFIG_COMMISSIONING_PROXY_MAX_CACHED_RESULTS < 1 || CHIP_CONFIG_COMMISSIONING_PROXY_MAX_CACHED_RESULTS > 255
+#error "CHIP_CONFIG_COMMISSIONING_PROXY_MAX_CACHED_RESULTS is not allowed to be a number less than 1 or greater than 255"
+#endif
+
+/**
+ * @def CHIP_CONFIG_COMMISSIONING_PROXY_MAX_BGSCAN_REQUESTS_PER_FABRIC
+ *
+ * @brief Concurrent ProxyBackGroundScanStartRequests retained per fabric. Each
+ *        requesting node gets its own record (the spec identifies a Stop by NodeID and
+ *        FabricID), so this bounds what one fabric can occupy.
+ */
+#ifndef CHIP_CONFIG_COMMISSIONING_PROXY_MAX_BGSCAN_REQUESTS_PER_FABRIC
+#define CHIP_CONFIG_COMMISSIONING_PROXY_MAX_BGSCAN_REQUESTS_PER_FABRIC 4
+#endif // CHIP_CONFIG_COMMISSIONING_PROXY_MAX_BGSCAN_REQUESTS_PER_FABRIC
+
+// Zero would declare a zero-length array, which compiles silently and leaves no room for
+// any request. The upper bound is the width of the uint8_t per-fabric request count.
+#if CHIP_CONFIG_COMMISSIONING_PROXY_MAX_BGSCAN_REQUESTS_PER_FABRIC < 1 ||                                                          \
+    CHIP_CONFIG_COMMISSIONING_PROXY_MAX_BGSCAN_REQUESTS_PER_FABRIC > 255
+#error                                                                                                                             \
+    "CHIP_CONFIG_COMMISSIONING_PROXY_MAX_BGSCAN_REQUESTS_PER_FABRIC is not allowed to be a number less than 1 or greater than 255"
+#endif
+
+/**
  * @def CHIP_CONFIG_MAX_NUM_ZONES
  *
  * @brief The maximum number of zones